├── .eslintignore ├── src ├── common │ ├── find.js │ ├── Options.vue │ ├── CascaderMixin.js │ └── Mixin.js ├── index.js ├── components │ ├── SelectBase.vue │ ├── SelectMulti.vue │ ├── Cascader.vue │ └── CascaderMulti.vue └── css │ └── index.scss ├── .gitignore ├── test └── index.spec.js ├── .npmignore ├── .prettierrc.js ├── .eslintrc.js ├── karma.conf.js ├── webpack.config.js ├── rollup.config.base.js ├── LICENSE ├── rollup.config.js ├── watch.js ├── index.d.ts ├── package.json ├── README.md └── examples └── test.html /.eslintignore: -------------------------------------------------------------------------------- 1 | # /node_modules/* and /bower_components/* in the project root are ignored by default 2 | 3 | /coverage/ 4 | /node_modules/ 5 | /lib/ 6 | /test/ 7 | 8 | **/*.ts 9 | 10 | **/*.spec.js 11 | **/*.test.js 12 | **/*.e2e.js 13 | **/webpack.**.js 14 | -------------------------------------------------------------------------------- /src/common/find.js: -------------------------------------------------------------------------------- 1 | export function find(arr, rule, defaultVal = {}) { 2 | let item = defaultVal 3 | arr.some(item1 => { 4 | if (rule(item1)) { 5 | item = item1 6 | return true 7 | } 8 | return false 9 | }) 10 | return item 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | package-lock.json 7 | yarn.lock 8 | 9 | # Editor directories and files 10 | .idea 11 | .vscode 12 | *.suo 13 | *.ntvs* 14 | *.njsproj 15 | *.sln 16 | 17 | /coverage/ 18 | /lib/ 19 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Cascader from './components/Cascader.vue' 2 | import CascaderMulti from './components/CascaderMulti.vue' 3 | import SelectBase from './components/SelectBase.vue' 4 | import SelectMulti from './components/SelectMulti.vue' 5 | 6 | export { SelectBase, SelectMulti, Cascader, CascaderMulti } 7 | -------------------------------------------------------------------------------- /test/index.spec.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils' 2 | import VueSelect from '../src/components/Index.vue' 3 | 4 | describe('Index.vue', () => { 5 | it('Rendered', () => { 6 | const wrapper = shallowMount(VueSelect) 7 | expect(wrapper.find('div').exists()).to.equal(true) 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log* 3 | yarn-debug.log* 4 | yarn-error.log* 5 | package-lock.json 6 | yarn.lock 7 | 8 | # Editor directories and files 9 | .idea 10 | .vscode 11 | *.suo 12 | *.ntvs* 13 | *.njsproj 14 | *.sln 15 | 16 | /coverage/ 17 | /examples/ 18 | /node_modules/ 19 | /src/ 20 | /test/ 21 | 22 | /karma.conf.js 23 | /.eslintrc.js 24 | /.eslintignore 25 | /.prettierrc.js 26 | /.babelrc.js 27 | /.tsconfig.json 28 | /webpack.*.js 29 | /rollup.*.js 30 | /watch.js 31 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | tabWidth: 2, 4 | semi: false, 5 | singleQuote: true, 6 | quoteProps: 'as-needed', 7 | jsxSingleQuote: false, 8 | trailingComma: 'all', 9 | bracketSpacing: true, 10 | jsxBracketSameLine: false, 11 | arrowParens: 'avoid', 12 | rangeStart: 0, 13 | rangeEnd: Infinity, 14 | requirePragma: false, 15 | insertPragma: false, 16 | proseWrap: 'preserve', 17 | htmlWhitespaceSensitivity: 'css', 18 | endOfLine: 'auto', 19 | } 20 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'parserOptions': { 3 | 'parser': 'babel-eslint', 4 | }, 5 | 'plugins': [ 6 | 'vue', 7 | 'prettier' 8 | ], 9 | 'extends': [ 10 | 'airbnb-base', 11 | 'plugin:vue/essential', 12 | 'plugin:prettier/recommended', 13 | ], 14 | 'rules': { 15 | 'import/prefer-default-export': 'off', 16 | 'no-param-reassign': 'off', 17 | 'prettier/prettier': 'error', 18 | }, 19 | 'settings': { 20 | 'import/resolver': { 21 | 'node': { 22 | 'extensions': ['.js', 'jsx', '.vue'], 23 | }, 24 | }, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const webpackConfig = require('./webpack.config.js') 2 | 3 | module.exports = config => { 4 | config.set({ 5 | // 浏览器环境 6 | browsers: ['Chrome'], 7 | // 测试框架 8 | frameworks: ['mocha', 'chai'], 9 | // 需要测试的文件,在 browsers 里面运行,使用 frameworks 测试js,通过 reporters 输出报告 10 | files: ['test/**/*.spec.js'], 11 | // 为入口文件制定预处理器,测试 js 之前用 webpack 和 sourcemap 处理一下 12 | preprocessors: { 13 | '**/*.spec.js': ['webpack', 'sourcemap'], 14 | }, 15 | webpack: webpackConfig, 16 | // 输出报告 17 | reporters: ['spec', 'coverage'], 18 | // 覆盖报告的配置 19 | coverageReporter: { 20 | dir: './coverage', 21 | reporters: [{ type: 'lcov', subdir: '.' }, { type: 'text-summary' }], 22 | }, 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* For test */ 2 | const VueLoaderPlugin = require('vue-loader/lib/plugin') 3 | 4 | const config = { 5 | mode: 'production', 6 | entry: { index: './src/index.js' }, 7 | module: { 8 | rules: [ 9 | { test: /\.vue$/, exclude: /node_modules/, loader: 'vue-loader' }, 10 | { 11 | test: /\.js$/, 12 | exclude: /node_modules/, 13 | loader: 'babel-loader', 14 | options: { 15 | presets: [ 16 | [ 17 | '@babel/preset-env', 18 | { 19 | modules: false, 20 | targets: { 21 | browsers: ['> 1%', 'last 2 versions', 'not ie <= 8'], 22 | }, 23 | }, 24 | ], 25 | ], 26 | plugins: ['@babel/plugin-transform-runtime', 'istanbul'], 27 | }, 28 | }, 29 | ], 30 | }, 31 | plugins: [new VueLoaderPlugin()], 32 | } 33 | 34 | module.exports = config 35 | -------------------------------------------------------------------------------- /rollup.config.base.js: -------------------------------------------------------------------------------- 1 | const commonjs = require('rollup-plugin-commonjs') 2 | const resolve = require('rollup-plugin-node-resolve') 3 | const vuePlugin = require('rollup-plugin-vue') 4 | const babel = require('rollup-plugin-babel') 5 | const { DEFAULT_EXTENSIONS } = require('@babel/core') 6 | 7 | const vue = vuePlugin.default || vuePlugin 8 | 9 | module.exports = { 10 | plugins: [ 11 | resolve({ 12 | extensions: [...DEFAULT_EXTENSIONS, '.vue'], 13 | }), 14 | commonjs(), 15 | vue({ css: true }), 16 | babel({ 17 | babelrc: false, 18 | externalHelpers: false, 19 | runtimeHelpers: true, 20 | extensions: [...DEFAULT_EXTENSIONS, '.vue'], 21 | presets: [ 22 | [ 23 | '@babel/preset-env', 24 | { 25 | modules: false, 26 | targets: { 27 | browsers: ['> 1%', 'last 2 versions', 'not ie <= 8'], 28 | }, 29 | }, 30 | ], 31 | ], 32 | }), 33 | ], 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 2631541504@qq.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/common/Options.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 70 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /* For build */ 2 | import fs from 'fs' 3 | import path from 'path' 4 | import license from 'rollup-plugin-license' 5 | import { uglify } from 'rollup-plugin-uglify' 6 | import packageConf from './package.json' 7 | 8 | const baseConf = require('./rollup.config.base') 9 | 10 | const formats = ['es', 'umd'] 11 | 12 | const isWatch = process.env.BUILD_ENV === 'watch' 13 | 14 | function getEntries() { 15 | const reg = /\.vue$/ 16 | return fs 17 | .readdirSync(path.resolve(__dirname, './src/components')) 18 | .filter( 19 | filename => 20 | reg.test(filename) && 21 | !fs 22 | .statSync(path.resolve(__dirname, './src/components', filename)) 23 | .isDirectory(), 24 | ) 25 | .map(filename => ({ 26 | name: filename.replace(reg, ''), 27 | filename: path.resolve(__dirname, './src/components', filename), 28 | formats: formats.filter(f => f !== 'es'), 29 | })) 30 | } 31 | 32 | const conf = entry => ({ 33 | ...baseConf, 34 | input: entry.filename, 35 | output: entry.formats.map(format => ({ 36 | file: `./lib/${format}/${entry.name}.js`, 37 | format, 38 | name: entry.name === 'index' ? 'VueSelect' : `${entry.name}VueSelect`, 39 | })), 40 | external: entry.external ? Object.keys(packageConf.dependencies || {}) : [], 41 | plugins: [ 42 | ...baseConf.plugins, 43 | entry.needUglify !== false && uglify(), 44 | license({ 45 | banner: `Bundle of <%= pkg.name %> 46 | Generated: <%= moment().format('YYYY-MM-DD') %> 47 | Version: <%= pkg.version %> 48 | License: <%= pkg.license %> 49 | Author: <%= pkg.author %>`, 50 | }), 51 | ], 52 | }) 53 | 54 | export default (isWatch 55 | ? [{ name: 'index', filename: './src/index.js', formats: ['umd'] }] 56 | : [ 57 | { 58 | name: 'index', 59 | filename: './src/index.js', 60 | formats: ['es'], 61 | needUglify: false, 62 | external: true, 63 | }, 64 | { name: 'index', filename: './src/index.js', formats: ['umd'] }, 65 | ...getEntries(), 66 | ] 67 | ).map(conf) 68 | -------------------------------------------------------------------------------- /src/components/SelectBase.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 73 | -------------------------------------------------------------------------------- /watch.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies, no-console */ 2 | const chokidar = require('chokidar') 3 | const spawn = require('cross-spawn') 4 | const chalk = require('chalk') 5 | const { singletonObj } = require('@livelybone/singleton') 6 | const express = require('express') 7 | const path = require('path') 8 | 9 | const port = process.env.PORT || 3000 10 | 11 | const watcher = chokidar.watch('src') 12 | 13 | const debounceTimer = { 14 | js: null, 15 | css: null, 16 | serve: null, 17 | } 18 | 19 | function serve() { 20 | return singletonObj( 21 | 'serve', 22 | () => 23 | new Promise(res => { 24 | const app = express() 25 | app.use('/examples', express.static(path.resolve('./examples'))) 26 | app.use('/lib', express.static(path.resolve('./lib'))) 27 | app.listen(port, e => { 28 | if (e) { 29 | console.log(chalk.red(e)) 30 | process.exit(1) 31 | } 32 | res() 33 | }) 34 | }), 35 | ).then(() => { 36 | debounceTimer.serve = setTimeout(() => { 37 | console.log( 38 | `\r\nThe example of your component is listening on ${port}...\r\n`, 39 | ) 40 | console.log( 41 | chalk.cyan( 42 | ` Open http://127.0.0.1:${port}/examples/test.html in your browser`, 43 | ), 44 | '\r\n', 45 | ) 46 | }, 200) 47 | }) 48 | } 49 | 50 | function spawnConsole(resource, ls) { 51 | ls.stdout.on('data', data => { 52 | if (debounceTimer.serve) clearTimeout(debounceTimer.serve) 53 | console.log(`${data}`.replace(/[\r\n]/g, '')) 54 | }) 55 | ls.stderr.on('data', data => { 56 | if (debounceTimer.serve) clearTimeout(debounceTimer.serve) 57 | console.log(`${data}`.replace(/[\r\n]/g, '')) 58 | }) 59 | ls.on('close', code => { 60 | if (+code === 0) { 61 | console.log('\r') 62 | console.log(chalk.cyan(`>>> ${resource} building successful`), '\r\n') 63 | serve() 64 | } 65 | }) 66 | } 67 | 68 | function build(cmd = 'build:js') { 69 | const resource = cmd.replace('build:', '') 70 | if (debounceTimer[resource]) clearTimeout(debounceTimer[resource]) 71 | debounceTimer[resource] = setTimeout(() => { 72 | console.log(chalk.cyan(`>>> Building for ${resource}...`), '\r\n') 73 | spawnConsole(resource, spawn('npm', ['run', cmd])) 74 | }, 1000) 75 | } 76 | 77 | watcher.on('all', (event, filename) => { 78 | const isCss = /.s?css$/ 79 | if (isCss.test(filename)) build('build:css') 80 | else build() 81 | }) 82 | -------------------------------------------------------------------------------- /src/common/CascaderMixin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @prop {Array} options 3 | * example: [{name: 'a', value:'1', children: [{name:'a1', value:'11', children:[...]}]}] 4 | * @prop {String} ['hover','click'] expandType, the action to trigger expand sub options 5 | * default: 'click' 6 | * */ 7 | export default { 8 | beforeMount() { 9 | this.initTemp() 10 | }, 11 | props: { 12 | value: { 13 | default() { 14 | return [] 15 | }, 16 | type: Array, 17 | }, 18 | expandType: String, 19 | }, 20 | data() { 21 | return { 22 | tempVal: [], 23 | optionsHeight: 0, 24 | optionsRight: 0, 25 | positionFixed: true, 26 | } 27 | }, 28 | computed: { 29 | $lineStyle() { 30 | return { height: `${this.optionsHeight}px` } 31 | }, 32 | showOptions() { 33 | return this.options.map(op => this.setSelect(op, 0)) 34 | }, 35 | selectedOptions() { 36 | const selected = this.getSelected(this.showOptions) 37 | return selected.map(s => s.children).filter(ops => ops) 38 | }, 39 | }, 40 | watch: { 41 | optionsHidden(val) { 42 | if (!val) { 43 | this.$nextTick(this.listenOptionsStyle) 44 | } 45 | }, 46 | selectedOptions(val) { 47 | if (val) { 48 | this.listenOptionsStyle() 49 | } 50 | }, 51 | }, 52 | methods: { 53 | listenOptionsStyle() { 54 | if (this.$refs.optionsEl) { 55 | const { clientHeight } = this.$refs.optionsEl.$el 56 | if (clientHeight && this.optionsHeight !== clientHeight) { 57 | this.optionsHeight = clientHeight 58 | } 59 | } 60 | }, 61 | getSelected(source, values = null) { 62 | const arr = values || this.tempVal 63 | const item0 = this.find(source, op => this.isSelected(op, arr[0]), '') 64 | return item0 65 | ? arr.slice(1).reduce( 66 | (pre, val) => { 67 | const options = pre[pre.length - 1].children 68 | if (options instanceof Array) { 69 | const item = this.find( 70 | options, 71 | op => this.isSelected(op, val), 72 | '', 73 | ) 74 | if (item) pre.push(item) 75 | } 76 | return pre 77 | }, 78 | [item0], 79 | ) 80 | : [] 81 | }, 82 | isSelected(op, val) { 83 | return op.value === val 84 | }, 85 | isEnd(op) { 86 | return !(op.children instanceof Array && op.children.length > 0) 87 | }, 88 | }, 89 | } 90 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { VuePopperProps } from '@livelybone/vue-popper' 2 | import Vue from 'vue' 3 | import { VueScrollbarProps } from 'vue-scrollbar-live' 4 | 5 | declare class Common extends Vue { 6 | id?: string | number 7 | /** 8 | * Default to true 9 | * */ 10 | canEdit?: boolean 11 | /** 12 | * Default to false 13 | * */ 14 | canSearch?: boolean 15 | placeholder?: string 16 | searchPlaceholder?: string 17 | /** 18 | * Props of popper.js 19 | * 20 | * Defaults to: 21 | * { 22 | * arrowPosition: 'start', 23 | * arrowOffsetScaling: 1, 24 | * popperOptions: { 25 | * placement: 'bottom-start', 26 | * modifiers: { 27 | * preventOverflow: { 28 | * boundariesElement: 29 | * typeof document !== 'undefined' ? document.body : '', 30 | * }, 31 | * }, 32 | * }, 33 | * } 34 | * */ 35 | popperProps?: VuePopperProps 36 | /** 37 | * Props of vue-scrollbar-live 38 | * 39 | * Default to: 40 | * { 41 | * isMobile: false, 42 | * maxHeight: '50vh', 43 | * } 44 | * */ 45 | scrollbarProps?: VueScrollbarProps 46 | } 47 | 48 | export interface SelectOptions { 49 | name: string 50 | value: string | number 51 | } 52 | 53 | declare class SelectBase extends Common { 54 | value: string | number 55 | options: SelectOptions 56 | inputWrapStyle?: CSSStyleDeclaration | string 57 | } 58 | 59 | declare class SelectMulti extends Common { 60 | value: Array 61 | options: SelectOptions 62 | } 63 | 64 | export interface CascaderOptions extends SelectOptions { 65 | children?: CascaderOptions 66 | } 67 | 68 | export type ExpandType = 'click' | 'hover' 69 | 70 | declare class Cascader extends Common { 71 | value: Array 72 | options: CascaderOptions 73 | /** 74 | * Set how to expand children options 75 | * 76 | * Default to 'click' 77 | * */ 78 | expandType?: ExpandType 79 | /** 80 | * If set to true, options of all level can be selected 81 | * 82 | * Default to false 83 | * */ 84 | changeOnSelect?: boolean 85 | inputWrapStyle?: CSSStyleDeclaration | string 86 | } 87 | 88 | declare class CasecaderMulti extends Common { 89 | value: Array> 90 | options: CascaderOptions 91 | /** 92 | * Set how to expand children options 93 | * 94 | * Default to 'click' 95 | * */ 96 | expandType?: ExpandType 97 | } 98 | 99 | export { SelectBase, SelectMulti, Cascader, CasecaderMulti } 100 | -------------------------------------------------------------------------------- /src/components/SelectMulti.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@livelybone/vue-select", 3 | "version": "2.7.0", 4 | "description": "A vue select component, includes cascader", 5 | "main": "./lib/umd/index.js", 6 | "module": "./lib/es/index.js", 7 | "unpkg": "./lib/umd/index.js", 8 | "css_path": "./lib/css/index.css", 9 | "scss_path": "./lib/css/index.scss", 10 | "types": "./index.d.ts", 11 | "scripts": { 12 | "build:css": "cross-env NODE_ENV=production node-sass ./src/css/index.scss ./lib/css/index.css --output-style compressed && ncp ./src/css/index.scss ./lib/css/index.scss", 13 | "build:js": "cross-env NODE_ENV=production rollup -c", 14 | "dev": "rimraf ./lib && cross-env BUILD_ENV=watch node watch.js", 15 | "build": "rimraf ./lib && npm run build:css && npm run build:js", 16 | "eslint": "eslint ./ --ext .vue,.js --fix", 17 | "test": "cross-env BABEL_ENV=test karma start --single-run", 18 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", 19 | "commit": "git-cz", 20 | "release": "npm publish --registry=https://registry.npmjs.org", 21 | "release:alpha": "npm publish --tag alpha --registry=https://registry.npmjs.org" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/livelybone/vue-select.git" 26 | }, 27 | "keywords": [ 28 | "vue", 29 | "select", 30 | "cascader" 31 | ], 32 | "author": "2631541504@qq.com", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/livelybone/vue-select/issues" 36 | }, 37 | "homepage": "https://github.com/livelybone/vue-select#readme", 38 | "devDependencies": { 39 | "@babel/core": "^7.5.0", 40 | "@babel/plugin-transform-runtime": "^7.5.0", 41 | "@babel/preset-env": "^7.5.0", 42 | "@babel/runtime": "^7.5.1", 43 | "@livelybone/singleton": "^1.1.1", 44 | "@vue/test-utils": "^1.0.0-beta.19", 45 | "babel-eslint": "^10.0.2", 46 | "babel-loader": "^8.0.6", 47 | "babel-plugin-istanbul": "^5.1.4", 48 | "chai": "^4.2.0", 49 | "chalk": "^2.4.2", 50 | "chokidar": "^3.0.2", 51 | "commitizen": "^3.0.7", 52 | "conventional-changelog-cli": "^2.0.12", 53 | "cross-env": "^5.2.0", 54 | "cross-spawn": "^6.0.5", 55 | "cz-conventional-changelog": "^2.1.0", 56 | "eslint": "^5.3.0", 57 | "eslint-config-airbnb-base": "^13.0.0", 58 | "eslint-config-prettier": "^6.0.0", 59 | "eslint-plugin-import": "^2.12.0", 60 | "eslint-plugin-prettier": "^3.1.0", 61 | "eslint-plugin-vue": "^5.1.0", 62 | "express": "^4.17.1", 63 | "husky": "^3.0.0", 64 | "karma": "^4.1.0", 65 | "karma-chai": "^0.1.0", 66 | "karma-chrome-launcher": "^2.2.0", 67 | "karma-coverage": "^1.1.2", 68 | "karma-mocha": "^1.3.0", 69 | "karma-sourcemap-loader": "^0.3.7", 70 | "karma-spec-reporter": "0.0.32", 71 | "karma-webpack": "^4.0.2", 72 | "lint-staged": "^9.2.0", 73 | "mocha": "^6.1.4", 74 | "ncp": "^2.0.0", 75 | "prettier": "^1.18.2", 76 | "rollup": "^1.16.7", 77 | "rollup-plugin-babel": "^4.3.3", 78 | "rollup-plugin-commonjs": "^10.0.1", 79 | "rollup-plugin-license": "^0.9.0", 80 | "rollup-plugin-node-resolve": "^5.2.0", 81 | "rollup-plugin-uglify": "^6.0.2", 82 | "rollup-plugin-vue": "^5.0.1", 83 | "vue": "^2.5.16", 84 | "vue-loader": "^15.2.4", 85 | "vue-template-compiler": "^2.5.16", 86 | "webpack": "^4.12.0", 87 | "webpack-command": "^0.2.1" 88 | }, 89 | "dependencies": { 90 | "@livelybone/copy": "^2.5.4", 91 | "@livelybone/vue-popper": "^2.3.1", 92 | "vue-scrollbar-live": "^5.2.2" 93 | }, 94 | "config": { 95 | "commitizen": { 96 | "path": "./node_modules/cz-conventional-changelog" 97 | } 98 | }, 99 | "husky": { 100 | "hooks": { 101 | "pre-commit": "lint-staged" 102 | } 103 | }, 104 | "lint-staged": { 105 | "**/*.{js,vue}": [ 106 | "eslint --fix", 107 | "git update-index --again" 108 | ], 109 | "**/*.scss": [ 110 | "prettier --write", 111 | "git update-index --again" 112 | ] 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/components/Cascader.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 129 | -------------------------------------------------------------------------------- /src/common/Mixin.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-useless-path-segments */ 2 | import { objectDeepMerge } from '@livelybone/copy' 3 | import VuePopper from '@livelybone/vue-popper' 4 | import Options from '../common/Options.vue' 5 | import { find } from './find' 6 | 7 | export default { 8 | components: { Options, popper: VuePopper }, 9 | props: { 10 | id: String, 11 | options: { 12 | default() { 13 | return [] 14 | }, 15 | type: Array, 16 | }, 17 | canEdit: { 18 | default: true, 19 | type: Boolean, 20 | }, 21 | canSearch: Boolean, 22 | placeholder: String, 23 | searchPlaceholder: String, 24 | popperProps: Object, 25 | scrollbarProps: Object, 26 | }, 27 | data() { 28 | return { 29 | mergedOptions: [], 30 | optionsHidden: true, 31 | shouldHide: true, 32 | inputVal: '', 33 | defaultPopperProps: Object.freeze({ 34 | arrowPosition: 'start', 35 | arrowOffsetScaling: 1, 36 | popperOptions: { 37 | placement: 'bottom-start', 38 | modifiers: { 39 | preventOverflow: { 40 | boundariesElement: 41 | typeof document !== 'undefined' ? document.body : '', 42 | }, 43 | }, 44 | }, 45 | }), 46 | } 47 | }, 48 | computed: { 49 | $placeholder() { 50 | return this.placeholder || '请选择' 51 | }, 52 | $searchPlaceholder() { 53 | return this.searchPlaceholder || '搜索' 54 | }, 55 | valid() { 56 | let valid = true 57 | valid = 58 | !this.options || 59 | !this.options.every(item => item.name && item.value !== undefined) 60 | if (!valid) { 61 | throw new Error( 62 | 'vue-select: Prop options is invalid! Right example: [{name: "option", value: 1}]', 63 | ) 64 | } 65 | return valid 66 | }, 67 | $popperProps() { 68 | return objectDeepMerge({}, this.defaultPopperProps, this.popperProps) 69 | }, 70 | $_select_isMobile() { 71 | const { isMobile } = this.scrollbarProps || {} 72 | return isMobile 73 | }, 74 | maxHeight() { 75 | const { maxHeight } = this.scrollbarProps || {} 76 | return maxHeight || '50vh' 77 | }, 78 | marginToWrap() { 79 | const { marginToWrap } = this.scrollbarProps || {} 80 | return marginToWrap || 2 81 | }, 82 | }, 83 | watch: { 84 | inputVal(val) { 85 | this.$emit('search', val) 86 | }, 87 | options(val) { 88 | this.mergedOptions = this.mergeOptions(this.mergedOptions, val) 89 | }, 90 | }, 91 | methods: { 92 | toggle(ev) { 93 | if (this.canEdit) { 94 | const isContains = ev && this.$refs.wrap.contains(ev.target) 95 | const containedInOptions = this.$refs.optionsEl.$el.contains(ev.target) 96 | 97 | // If `ev.target` is the child of DOM `div.options`, do nothing 98 | if (!containedInOptions) { 99 | if (this.optionsHidden && isContains) { 100 | this.optionsHidden = false 101 | if (this.canSearch) this.$nextTick(() => this.$refs.input.focus()) 102 | if ('initTemp' in this) this.initTemp() 103 | } else { 104 | if (this.shouldHide) { 105 | this.optionsHidden = true 106 | } 107 | this.shouldHide = true 108 | } 109 | } 110 | } 111 | }, 112 | endDrag() { 113 | setTimeout(() => { 114 | this.shouldHide = true 115 | }, 100) 116 | }, 117 | bind(bool) { 118 | if (typeof window !== 'undefined') { 119 | window[`${bool ? 'add' : 'remove'}EventListener`]( 120 | 'click', 121 | this.toggle, 122 | true, 123 | ) 124 | } 125 | }, 126 | find, 127 | mergeOptions(a1, a2) { 128 | const obj = [...a1, ...a2].reduce((pre, item) => { 129 | const item1 = pre[item.value] 130 | if (item1 && item1.name !== item.name) { 131 | throw new Error( 132 | `vue-select: the options at same level have conflict items(name: ${item.name} & name: ${item1.name}) that have the same value`, 133 | ) 134 | } else if ( 135 | item1 && 136 | (item1.children instanceof Array || item.children instanceof Array) 137 | ) { 138 | pre[item.value] = { 139 | ...item, 140 | children: this.mergeOptions(item1.children, item.children), 141 | } 142 | } else pre[item.value] = item 143 | return pre 144 | }, {}) 145 | return Object.keys(obj).map(k => obj[k]) 146 | }, 147 | }, 148 | beforeMount() { 149 | this.mergedOptions = [...this.options] 150 | this.bind(true) 151 | }, 152 | beforeDestroy() { 153 | this.bind(false) 154 | }, 155 | } 156 | -------------------------------------------------------------------------------- /src/components/CascaderMulti.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @livelybone/vue-select 2 | [![NPM Version](http://img.shields.io/npm/v/@livelybone/vue-select.svg?style=flat-square)](https://www.npmjs.com/package/@livelybone/vue-select) 3 | [![Download Month](http://img.shields.io/npm/dm/@livelybone/vue-select.svg?style=flat-square)](https://www.npmjs.com/package/@livelybone/vue-select) 4 | ![gzip with dependencies: 15kb](https://img.shields.io/badge/gzip--with--dependencies-15kb-brightgreen.svg "gzip with dependencies: 15kb") 5 | ![typescript](https://img.shields.io/badge/typescript-supported-blue.svg "typescript") 6 | ![pkg.module](https://img.shields.io/badge/pkg.module-supported-blue.svg "pkg.module") 7 | ![ssr supported](https://img.shields.io/badge/ssr-supported-blue.svg "ssr supported") 8 | 9 | > `pkg.module supported`, which means that you can apply tree-shaking in you project 10 | 11 | A vue select component, includes cascader 12 | 13 | ## repository 14 | https://github.com/livelybone/vue-select 15 | 16 | ## Demo 17 | https://livelybone.github.io/vue/vue-select/ 18 | 19 | ## Run Example 20 | Your can see the usage by run the example of the module, here is the step: 21 | 22 | 1. Clone the library `git clone https://github.com/livelybone/vue-select.git` 23 | 2. Go to the directory `cd vue-select` 24 | 3. Install npm dependencies `npm i`(use taobao registry: `npm i --registry=http://registry.npm.taobao.org`) 25 | 4. Open service `npm run dev` 26 | 5. See the example(usually is `http://127.0.0.1/examples/test.html`) in your browser 27 | 28 | ## Installation 29 | ```bash 30 | npm i -S @livelybone/vue-select 31 | ``` 32 | 33 | ## Register 34 | ```js 35 | // import all 36 | import {SelectBase, SelectMulti, Cascader, CascaderMulti} from '@livelybone/vue-select'; 37 | // or 38 | import * as VueSelect from '@livelybone/vue-select'; 39 | 40 | // Global register 41 | Vue.component('select-base', SelectBase); 42 | Vue.component('select-multi', SelectMulti); 43 | Vue.component('cascader', Cascader); 44 | Vue.component('cascader-multi', CascaderMulti); 45 | 46 | // Local register 47 | new Vue({ 48 | components:{SelectBase, SelectMulti, Cascader, CascaderMulti} 49 | }) 50 | ``` 51 | 52 | Use in html, see what your can use in [CDN: unpkg](https://unpkg.com/@livelybone/vue-select/lib/umd/) 53 | ```html 54 | <-- use what you want --> 55 | 56 | ``` 57 | 58 | ## Props 59 | 60 | ### Common 61 | | Name | Type | DefaultValue | Description | 62 | | ------------------------- | ----------------------------------------- | --------------------------------------------- | ------------ | 63 | | `id` | `[String, Number]` | none | | 64 | | `options` | `Array` | `[]` | Select options | 65 | | `canEdit` | `Boolean` | `true` | If it's set to false, the component can only be used for show | 66 | | `canSearch` | `Boolean` | `false` | Set to true to enable search | 67 | | `placeholder` | `String` | none | Placeholder | 68 | | `searchPlaceholder` | `String` | `true` | Placeholder of search input | 69 | | `popperProps` | `Object` | `defaultPopperProps` | Props of module [@livelybone/vue-popper](https://github.com/livelybone/vue-popper) | 70 | | `scrollbarProps` | `Object` | `{isMobile:false, maxHeight: '50vh'}` | Props of module [@livelybone/vue-scrollbar-live](https://github.com/livelybone/vue-scrollbar-live) | 71 | 72 | ```js 73 | const defaultPopperProps = { 74 | arrowPosition: 'start', 75 | arrowOffsetScaling: 1, 76 | popperOptions: { 77 | placement: 'bottom-start', 78 | // If component is Cascader or CascaderMulti -> `positionFixed: true` 79 | // More options in https://popper.js.org 80 | }, 81 | } 82 | ``` 83 | 84 | ### SelectBase 85 | | Name | Type | DefaultValue | Description | 86 | | ----------------- | --------------------- | --------------------- | ------------ | 87 | | `value` | `[String, Number]` | none | | 88 | | `inputWrapStyle` | `[String, Object]` | none | | 89 | 90 | ### SelectMulti 91 | | Name | Type | DefaultValue | Description | 92 | | ----------------- | --------------------- | --------------------- | ------------ | 93 | | `value` | `Array` | none | | 94 | 95 | ### Cascader 96 | | Name | Type | DefaultValue | Description | 97 | | ----------------- | --------------------- | --------------------- | ------------ | 98 | | `value` | `Array` | none | | 99 | | `expandType` | `String` | `click` | Options: `['click', 'hover']`. Set how to expand children options | 100 | | `changeOnSelect` | `Boolean` | `false` | If set to true, options of all level can be selected | 101 | | `inputWrapStyle` | `[String, Object]` | none | input wrap style | 102 | 103 | ### CascaderMulti 104 | | Name | Type | DefaultValue | Description | 105 | | ----------------- | --------------------- | --------------------- | ------------ | 106 | | `value` | `Array` | none | | 107 | | `expandType` | `String` | `click` | Options: `['click', 'hover']`. Set how to expand children options | 108 | 109 | ## Events 110 | | Name | EmittedData | Description | 111 | | ----------------- | --------------------- | ------------------------------------------------- | 112 | | `input` | `[Array, String]` | | 113 | | `search` | `String` | | 114 | 115 | ## style 116 | For building style, you can use the css or scss file in lib directory. 117 | ```js 118 | // scss 119 | import 'node_modules/@livelybone/vue-select/lib/css/index.scss' 120 | 121 | // css 122 | import 'node_modules/@livelybone/vue-select/lib/css/index.css' 123 | ``` 124 | Or 125 | ```scss 126 | // scss 127 | @import 'node_modules/@livelybone/vue-select/lib/css/index.scss'; 128 | 129 | // css 130 | @import 'node_modules/@livelybone/vue-select/lib/css/index.css'; 131 | ``` 132 | 133 | Or, you can build your custom style by copying and editing `index.scss` 134 | 135 | ## QA 136 | 137 | 1. Error `Error: spawn node-sass ENOENT` 138 | 139 | > You may need install node-sass globally, `npm i -g node-sass` 140 | -------------------------------------------------------------------------------- /src/css/index.scss: -------------------------------------------------------------------------------- 1 | $main: #30b386; 2 | $border: #c2ccdc; 3 | $font: #666; 4 | $placeholder: #aaa; 5 | $select: #fff; 6 | $input-bg: #fff; 7 | $input-bg-disabled: #f9f9f9; 8 | $base-size: 10px; 9 | $option-hover-bg: lighten($main, 50%); 10 | $shadow: rgba(0, 0, 0, 0.1); 11 | $multi-value-color: #eee; 12 | 13 | @function size($factor) { 14 | @return $factor * $base-size; 15 | } 16 | 17 | $option-height: size(3); 18 | $multi-value-height: size(2.4); 19 | $scrollbar-wrap-min-width: size(16); 20 | $option-padding: size(0.4); 21 | 22 | @mixin placeholder($color: $placeholder) { 23 | &::-webkit-input-placeholder { 24 | /* WebKit browsers */ 25 | color: $color; 26 | } 27 | 28 | &:-moz-placeholder { 29 | /* Mozilla Firefox 4 to 18 */ 30 | color: $color; 31 | } 32 | 33 | &::-moz-placeholder { 34 | /* Mozilla Firefox 19+ */ 35 | color: $color; 36 | } 37 | 38 | &:-ms-input-placeholder { 39 | /* Internet Explorer 10+ */ 40 | color: $color; 41 | } 42 | } 43 | 44 | @mixin middleLine($color) { 45 | content: ''; 46 | position: absolute; 47 | left: 0; 48 | right: 0; 49 | top: 50%; 50 | height: 1px; 51 | background: $color; 52 | } 53 | 54 | @mixin dot($color) { 55 | content: ''; 56 | position: absolute; 57 | left: 50%; 58 | top: 50%; 59 | width: size(0.6); 60 | height: size(0.6); 61 | margin: size(-0.3) 0 0 size(-0.3); 62 | border-radius: size(0.3); 63 | background: $color; 64 | } 65 | 66 | /* scrollbar css */ 67 | .scrollbar-wrap .scrollbar { 68 | width: size(0.4) !important; 69 | border-radius: size(0.6) !important; 70 | background: #eee !important; 71 | } 72 | 73 | /* popper css */ 74 | $bg: $input-bg; 75 | $pseudoSize: size(0.6); 76 | 77 | @mixin pseudo($bg: $border, $direction: top, $borderWidth: $pseudoSize) { 78 | position: absolute; 79 | #{$direction}: -$pseudoSize; 80 | width: 0; 81 | height: 0; 82 | border: $borderWidth solid transparent; 83 | 84 | @if ($direction==top) { 85 | border-top: 0; 86 | border-bottom-color: $bg; 87 | } @else if ($direction==bottom) { 88 | border-bottom: 0; 89 | border-top-color: $bg; 90 | } @else if ($direction==left) { 91 | border-left: 0; 92 | border-right-color: $bg; 93 | } @else if ($direction==right) { 94 | border-right: 0; 95 | border-left-color: $bg; 96 | } 97 | } 98 | 99 | .vue-popper { 100 | border-color: rgba($border, 0.5) !important; 101 | 102 | &[x-placement^='bottom'] .arrow { 103 | border-bottom-color: rgba($border, 0.5) !important; 104 | } 105 | &[x-placement^='top'] .arrow { 106 | border-top-color: rgba($border, 0.5) !important; 107 | } 108 | } 109 | 110 | .split { 111 | color: $placeholder !important; 112 | } 113 | 114 | .select-base, 115 | .select-multi, 116 | .cascader, 117 | .cascader-multi { 118 | position: relative; 119 | padding: 0 size(1); 120 | font-size: size(1.4); 121 | color: $font; 122 | border: 1px solid $border; 123 | border-radius: size(0.3); 124 | background: transparent; 125 | cursor: pointer; 126 | 127 | * { 128 | box-sizing: border-box; 129 | outline: none !important; 130 | } 131 | 132 | .input { 133 | cursor: pointer; 134 | } 135 | 136 | &.disabled { 137 | background: $input-bg-disabled; 138 | cursor: default; 139 | } 140 | 141 | &.select-multi, 142 | &.cascader, 143 | &.cascader-multi { 144 | .options .option { 145 | &.selected { 146 | color: $main !important; 147 | background: transparent !important; 148 | font-weight: 600; 149 | 150 | .icon-selected { 151 | float: right; 152 | position: relative; 153 | width: $option-height/2; 154 | height: 100%; 155 | 156 | &:before { 157 | @include dot($main); 158 | } 159 | } 160 | 161 | .icon-expand:before { 162 | border-left-color: $main; 163 | } 164 | } 165 | 166 | &:hover { 167 | background: $option-hover-bg !important; 168 | 169 | .icon-expand:after { 170 | border-left-color: $option-hover-bg; 171 | } 172 | } 173 | 174 | .icon-expand { 175 | display: block; 176 | float: right; 177 | position: relative; 178 | top: calc((#{$option-height} - #{size(1)}) / 2); 179 | width: size(1); 180 | height: size(1); 181 | 182 | &:before { 183 | content: ''; 184 | @include pseudo($font, right, size(0.5)); 185 | right: 0; 186 | } 187 | 188 | &:after { 189 | content: ''; 190 | @include pseudo($input-bg, right, size(0.5)); 191 | right: 1px; 192 | } 193 | } 194 | } 195 | } 196 | 197 | &.cascader, 198 | &.cascader-multi { 199 | .options { 200 | display: -webkit-box; 201 | display: -webkit-flex; 202 | display: -ms-flexbox; 203 | display: flex; 204 | right: auto; 205 | width: auto; 206 | 207 | .scrollbar-wrap { 208 | width: auto; 209 | min-width: $scrollbar-wrap-min-width; 210 | } 211 | 212 | .option { 213 | &.selected { 214 | font-weight: 500; 215 | } 216 | } 217 | 218 | .line { 219 | float: left; 220 | width: 1px; 221 | margin: -$option-padding 0; 222 | background: rgba($border, 0.5); 223 | } 224 | } 225 | } 226 | 227 | .value, 228 | .input { 229 | width: 100%; 230 | height: $option-height; 231 | line-height: $option-height; 232 | margin: 0; 233 | padding: 0; 234 | border: none; 235 | } 236 | 237 | .placeholder { 238 | color: $placeholder; 239 | } 240 | 241 | .input { 242 | display: block; 243 | outline: none !important; 244 | 245 | @include placeholder($placeholder); 246 | } 247 | 248 | .values { 249 | min-height: $option-height; 250 | height: auto; 251 | overflow: hidden; 252 | } 253 | 254 | .val { 255 | display: inline-block; 256 | width: auto; 257 | height: $multi-value-height; 258 | line-height: $multi-value-height; 259 | padding: 0 size(0.6); 260 | margin: ($option-height - $multi-value-height)/2; 261 | margin-left: 0; 262 | border-radius: size(0.2); 263 | background: $multi-value-color; 264 | white-space: nowrap; 265 | vertical-align: top; 266 | 267 | &.input, 268 | &.placeholder { 269 | background: transparent; 270 | } 271 | 272 | .v { 273 | float: left; 274 | font-size: size(1.2); 275 | } 276 | 277 | .icon-del { 278 | float: right; 279 | margin: 0 0 0 size(1); 280 | position: relative; 281 | width: $multi-value-height / 2; 282 | height: 100%; 283 | 284 | &:hover { 285 | &:before, 286 | &:after { 287 | background: darken($font, 20%); 288 | } 289 | } 290 | 291 | &:before, 292 | &:after { 293 | @include middleLine($font); 294 | transform: rotate(45deg); 295 | } 296 | 297 | &:after { 298 | transform: rotate(-45deg); 299 | } 300 | } 301 | } 302 | 303 | .icon-arrow { 304 | display: block; 305 | position: absolute; 306 | right: size(0.4); 307 | top: 50%; 308 | width: size(1); 309 | height: size(0.5); 310 | margin: size(-0.25) 0 0; 311 | transition: transform 0.3s ease; 312 | 313 | &:before { 314 | content: ''; 315 | @include pseudo($font, bottom, size(0.5)); 316 | bottom: 0; 317 | } 318 | 319 | &:after { 320 | content: ''; 321 | @include pseudo($input-bg, bottom, size(0.5)); 322 | bottom: 1px; 323 | } 324 | 325 | &.reverse { 326 | transform: rotate(180deg); 327 | } 328 | } 329 | 330 | .options { 331 | width: 100%; 332 | padding: $option-padding 0; 333 | z-index: 9999; 334 | box-shadow: 0 1px size(1) $shadow; 335 | 336 | .option { 337 | height: $option-height; 338 | line-height: $option-height; 339 | padding: 0 size(1); 340 | 341 | &:hover { 342 | background: $option-hover-bg !important; 343 | } 344 | 345 | &.selected { 346 | color: $select !important; 347 | background: $main !important; 348 | } 349 | } 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /examples/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | vue-select 7 | 8 | 9 | 10 | 17 | 24 | 25 | 26 |
27 |
28 |
29 |
30 | 31 | 308 | 309 | 310 | --------------------------------------------------------------------------------