├── .npmrc ├── docs ├── CNAME ├── partials │ ├── sidebar.pug │ ├── api.pug │ ├── introduction.pug │ ├── dev.pug │ ├── getting-started.pug │ └── guides.pug ├── components │ ├── dev │ │ ├── PerformanceTest.vue │ │ ├── ModalTest.vue │ │ └── LongLabelTest.vue │ ├── DisableBranchNodes.vue │ ├── FlattenSearchResults.vue │ ├── TreeselectValue.vue │ ├── CustomizeKeyNames.vue │ ├── CustomizeValueLabel.vue │ ├── CustomizeOptionLabel.vue │ ├── AsyncSearching.vue │ ├── VuexSupport.vue │ ├── Anchor.vue │ ├── DisableItemSelection.vue │ ├── FlatModeAndSortValues.vue │ ├── utils.js │ ├── DelayedRootOptions.vue │ ├── Demo.vue │ ├── BasicFeatures.vue │ ├── DocSlots.vue │ ├── DocEvents.vue │ ├── PreventValueCombining.vue │ ├── DelayedLoading.vue │ ├── NestedSearch.vue │ ├── MoreFeatures.vue │ └── DocNode.vue ├── browserconfig.xml ├── main.js ├── mixins.pug ├── styles │ ├── prism.less │ └── docs.less └── index.pug ├── .eslintignore ├── src ├── utils │ ├── last.js │ ├── noop.js │ ├── once.js │ ├── createMap.js │ ├── isNaN.js │ ├── isPromise.js │ ├── constant.js │ ├── debounce.js │ ├── identity.js │ ├── includes.js │ ├── .eslintrc.js │ ├── removeFromArray.js │ ├── find.js │ ├── onLeftClick.js │ ├── quickDiff.js │ ├── warning.js │ ├── deepExtend.js │ ├── scrollIntoView.js │ ├── index.js │ ├── setupResizeAndScrollEventListeners.js │ └── watchSize.js ├── assets │ ├── checkbox-checked.png │ ├── checkbox-checked@2x.png │ ├── checkbox-checked@3x.png │ ├── checkbox-indeterminate.png │ ├── checkbox-checked-disabled.png │ ├── checkbox-indeterminate@2x.png │ ├── checkbox-indeterminate@3x.png │ ├── checkbox-checked-disabled@2x.png │ ├── checkbox-checked-disabled@3x.png │ ├── checkbox-indeterminate-disabled.png │ ├── checkbox-indeterminate-disabled@2x.png │ └── checkbox-indeterminate-disabled@3x.png ├── index.js ├── components │ ├── icons │ │ ├── Arrow.vue │ │ └── Delete.vue │ ├── Placeholder.vue │ ├── Tip.vue │ ├── SingleValue.vue │ ├── HiddenFields.vue │ ├── MultiValueItem.vue │ ├── Treeselect.vue │ ├── MultiValue.vue │ ├── Control.vue │ ├── MenuPortal.vue │ ├── Input.vue │ ├── Option.vue │ └── Menu.vue └── constants.js ├── .browserslistrc ├── screenshot.png ├── static ├── vue-logo.png ├── images │ └── icons │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── ms-icon-70x70.png │ │ ├── apple-icon-57x57.png │ │ ├── apple-icon-60x60.png │ │ ├── apple-icon-72x72.png │ │ ├── apple-icon-76x76.png │ │ ├── ms-icon-144x144.png │ │ ├── ms-icon-150x150.png │ │ ├── ms-icon-310x310.png │ │ ├── apple-icon-114x114.png │ │ ├── apple-icon-120x120.png │ │ ├── apple-icon-144x144.png │ │ ├── apple-icon-152x152.png │ │ ├── apple-icon-180x180.png │ │ └── android-icon-192x192.png └── prism.min.js ├── .postcssrc.js ├── test └── unit │ ├── .eslintrc.js │ ├── index.js │ ├── specs │ ├── Control.spec.js │ ├── Slots.spec.js │ ├── Events.spec.js │ ├── SearchInput.spec.js │ ├── HiddenFields.spec.js │ ├── Menu.spec.js │ ├── shared.js │ ├── utils.spec.js │ └── Methods.spec.js │ └── karma.config.js ├── .gitignore ├── .size-limit.js ├── .editorconfig ├── .babelrc.js ├── .circleci └── config.yml ├── stylelint.config.js ├── .eslintrc.js ├── LICENSE ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | vue-treeselect.js.org 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | static 3 | test/unit/coverage 4 | -------------------------------------------------------------------------------- /src/utils/last.js: -------------------------------------------------------------------------------- 1 | export { default as last } from 'lodash/last' 2 | -------------------------------------------------------------------------------- /src/utils/noop.js: -------------------------------------------------------------------------------- 1 | export { default as noop } from 'lodash/noop' 2 | -------------------------------------------------------------------------------- /src/utils/once.js: -------------------------------------------------------------------------------- 1 | export { default as once } from 'lodash/once' 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # Browsers that we support 2 | 3 | > 0.5% 4 | IE >= 9 5 | -------------------------------------------------------------------------------- /src/utils/createMap.js: -------------------------------------------------------------------------------- 1 | export const createMap = () => Object.create(null) 2 | -------------------------------------------------------------------------------- /src/utils/isNaN.js: -------------------------------------------------------------------------------- 1 | export function isNaN(x) { 2 | return x !== x 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/isPromise.js: -------------------------------------------------------------------------------- 1 | export { default as isPromise } from 'is-promise' 2 | -------------------------------------------------------------------------------- /src/utils/constant.js: -------------------------------------------------------------------------------- 1 | export { default as constant } from 'lodash/constant' 2 | -------------------------------------------------------------------------------- /src/utils/debounce.js: -------------------------------------------------------------------------------- 1 | export { default as debounce } from 'lodash/debounce' 2 | -------------------------------------------------------------------------------- /src/utils/identity.js: -------------------------------------------------------------------------------- 1 | export { default as identity } from 'lodash/identity' 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/screenshot.png -------------------------------------------------------------------------------- /static/vue-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/vue-logo.png -------------------------------------------------------------------------------- /src/utils/includes.js: -------------------------------------------------------------------------------- 1 | export function includes(arrOrStr, elem) { 2 | return arrOrStr.indexOf(elem) !== -1 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/checkbox-checked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/src/assets/checkbox-checked.png -------------------------------------------------------------------------------- /src/assets/checkbox-checked@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/src/assets/checkbox-checked@2x.png -------------------------------------------------------------------------------- /src/assets/checkbox-checked@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/src/assets/checkbox-checked@3x.png -------------------------------------------------------------------------------- /src/assets/checkbox-indeterminate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/src/assets/checkbox-indeterminate.png -------------------------------------------------------------------------------- /static/images/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/favicon-16x16.png -------------------------------------------------------------------------------- /static/images/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/favicon-32x32.png -------------------------------------------------------------------------------- /static/images/icons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/favicon-96x96.png -------------------------------------------------------------------------------- /static/images/icons/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/ms-icon-70x70.png -------------------------------------------------------------------------------- /src/assets/checkbox-checked-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/src/assets/checkbox-checked-disabled.png -------------------------------------------------------------------------------- /src/assets/checkbox-indeterminate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/src/assets/checkbox-indeterminate@2x.png -------------------------------------------------------------------------------- /src/assets/checkbox-indeterminate@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/src/assets/checkbox-indeterminate@3x.png -------------------------------------------------------------------------------- /static/images/icons/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/apple-icon-57x57.png -------------------------------------------------------------------------------- /static/images/icons/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/apple-icon-60x60.png -------------------------------------------------------------------------------- /static/images/icons/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/apple-icon-72x72.png -------------------------------------------------------------------------------- /static/images/icons/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/apple-icon-76x76.png -------------------------------------------------------------------------------- /static/images/icons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/ms-icon-144x144.png -------------------------------------------------------------------------------- /static/images/icons/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/ms-icon-150x150.png -------------------------------------------------------------------------------- /static/images/icons/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/ms-icon-310x310.png -------------------------------------------------------------------------------- /static/images/icons/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/apple-icon-114x114.png -------------------------------------------------------------------------------- /static/images/icons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/apple-icon-120x120.png -------------------------------------------------------------------------------- /static/images/icons/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/apple-icon-144x144.png -------------------------------------------------------------------------------- /static/images/icons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/apple-icon-152x152.png -------------------------------------------------------------------------------- /static/images/icons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/apple-icon-180x180.png -------------------------------------------------------------------------------- /src/assets/checkbox-checked-disabled@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/src/assets/checkbox-checked-disabled@2x.png -------------------------------------------------------------------------------- /src/assets/checkbox-checked-disabled@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/src/assets/checkbox-checked-disabled@3x.png -------------------------------------------------------------------------------- /static/images/icons/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/static/images/icons/android-icon-192x192.png -------------------------------------------------------------------------------- /src/assets/checkbox-indeterminate-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/src/assets/checkbox-indeterminate-disabled.png -------------------------------------------------------------------------------- /src/assets/checkbox-indeterminate-disabled@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/src/assets/checkbox-indeterminate-disabled@2x.png -------------------------------------------------------------------------------- /src/assets/checkbox-indeterminate-disabled@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riophae/vue-treeselect/HEAD/src/assets/checkbox-indeterminate-disabled@3x.png -------------------------------------------------------------------------------- /src/utils/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'import/no-default-export': 2, 4 | 'import/prefer-default-export': 0, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | module.exports = { 4 | plugins: { 5 | autoprefixer: {}, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/removeFromArray.js: -------------------------------------------------------------------------------- 1 | export function removeFromArray(arr, elem) { 2 | const idx = arr.indexOf(elem) 3 | if (idx !== -1) arr.splice(idx, 1) 4 | } 5 | -------------------------------------------------------------------------------- /test/unit/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jasmine: true, 4 | }, 5 | rules: { 6 | 'node/no-unpublished-import': 0, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /docs/partials/sidebar.pug: -------------------------------------------------------------------------------- 1 | - const customClass = '{ sticky: isNavSticky }' 2 | div.sidebar-nav(:class=customClass) 3 | //- replaced later in mixins.pug:renderToc() 4 | ='%%_TOC_%%' 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | *debug.log* 4 | *error.log* 5 | 6 | .eslintcache 7 | 8 | node_modules/ 9 | yarn.lock 10 | 11 | dist 12 | gh-pages 13 | 14 | test/unit/coverage 15 | -------------------------------------------------------------------------------- /src/utils/find.js: -------------------------------------------------------------------------------- 1 | export function find(arr, predicate, ctx) { 2 | for (let i = 0, len = arr.length; i < len; i++) { 3 | if (predicate.call(ctx, arr[i], i, arr)) return arr[i] 4 | } 5 | return undefined 6 | } 7 | -------------------------------------------------------------------------------- /docs/partials/api.pug: -------------------------------------------------------------------------------- 1 | +section('API') 2 | 3 | +subsection('Node') 4 | doc-node 5 | 6 | +subsection('Props') 7 | doc-props 8 | 9 | +subsection('Events') 10 | doc-events 11 | 12 | +subsection('Slots') 13 | doc-slots 14 | -------------------------------------------------------------------------------- /.size-limit.js: -------------------------------------------------------------------------------- 1 | const shared = { webpack: false, running: false } 2 | 3 | module.exports = [ 4 | { path: "dist/vue-treeselect.umd.min.js", limit: "16.5 KB", ...shared }, 5 | { path: "dist/vue-treeselect.min.css", limit: "5 KB", ...shared }, 6 | ] 7 | -------------------------------------------------------------------------------- /src/utils/onLeftClick.js: -------------------------------------------------------------------------------- 1 | export function onLeftClick(mouseDownHandler) { 2 | return function onMouseDown(evt, ...args) { 3 | if (evt.type === 'mousedown' && evt.button === 0) { 4 | mouseDownHandler.call(this, evt, ...args) 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/quickDiff.js: -------------------------------------------------------------------------------- 1 | export function quickDiff(arrA, arrB) { 2 | if (arrA.length !== arrB.length) return true 3 | 4 | for (let i = 0; i < arrA.length; i++) { 5 | if (arrA[i] !== arrB[i]) return true 6 | } 7 | 8 | return false 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /test/unit/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | Vue.config.productionTip = false 4 | Vue.config.devtools = false 5 | 6 | function importAll(r) { 7 | r.keys().forEach(r) 8 | } 9 | 10 | importAll(require.context('./specs', true, /\.spec$/)) 11 | importAll(require.context('../../src', true)) 12 | -------------------------------------------------------------------------------- /docs/components/dev/PerformanceTest.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Treeselect from './components/Treeselect' 2 | import treeselectMixin from './mixins/treeselectMixin' 3 | import './style.less' 4 | 5 | export default Treeselect 6 | export { Treeselect, treeselectMixin } 7 | export { 8 | // Delayed loading. 9 | LOAD_ROOT_OPTIONS, 10 | LOAD_CHILDREN_OPTIONS, 11 | ASYNC_SEARCH, 12 | } from './constants' 13 | 14 | export const VERSION = PKG_VERSION 15 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = api => { 2 | api.cache.never() 3 | 4 | const presets = [ 5 | [ '@babel/preset-env', { modules: false } ], 6 | ] 7 | const plugins = [ 8 | 'transform-vue-jsx', 9 | '@babel/plugin-transform-runtime', 10 | ] 11 | 12 | if (process.env.NODE_ENV === 'testing') { 13 | plugins.push('istanbul') 14 | } 15 | 16 | return { presets, plugins, comments: false } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/warning.js: -------------------------------------------------------------------------------- 1 | import { noop } from './noop' 2 | 3 | export const warning = process.env.NODE_ENV === 'production' 4 | ? /* istanbul ignore next */ noop 5 | : function warning(checker, complainer) { 6 | if (!checker()) { 7 | const message = [ '[Vue-Treeselect Warning]' ].concat(complainer()) 8 | // eslint-disable-next-line no-console 9 | console.error(...message) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /docs/components/DisableBranchNodes.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 19 | -------------------------------------------------------------------------------- /docs/components/FlattenSearchResults.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 19 | -------------------------------------------------------------------------------- /docs/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | #ffffff 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/components/TreeselectValue.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 21 | -------------------------------------------------------------------------------- /src/components/icons/Arrow.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/Placeholder.vue: -------------------------------------------------------------------------------- 1 | 22 | -------------------------------------------------------------------------------- /docs/components/CustomizeKeyNames.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 31 | -------------------------------------------------------------------------------- /docs/components/CustomizeValueLabel.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 25 | -------------------------------------------------------------------------------- /src/utils/deepExtend.js: -------------------------------------------------------------------------------- 1 | function isPlainObject(value) { 2 | if (value == null || typeof value !== 'object') return false 3 | return Object.getPrototypeOf(value) === Object.prototype 4 | } 5 | 6 | function copy(obj, key, value) { 7 | if (isPlainObject(value)) { 8 | obj[key] || (obj[key] = {}) 9 | deepExtend(obj[key], value) 10 | } else { 11 | obj[key] = value 12 | } 13 | } 14 | 15 | export function deepExtend(target, source) { 16 | if (isPlainObject(source)) { 17 | const keys = Object.keys(source) 18 | 19 | for (let i = 0, len = keys.length; i < len; i++) { 20 | copy(target, keys[i], source[keys[i]]) 21 | } 22 | } 23 | 24 | return target 25 | } 26 | -------------------------------------------------------------------------------- /test/unit/specs/Control.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { leftClick } from './shared' 3 | import Treeselect from '@src/components/Treeselect' 4 | 5 | describe('Control', () => { 6 | it('should toggle the menu when the arrow is clicked', () => { 7 | const wrapper = mount(Treeselect, { 8 | sync: false, 9 | attachToDocument: true, 10 | propsData: { 11 | options: [], 12 | }, 13 | }) 14 | const arrow = wrapper.find('.vue-treeselect__control-arrow-container') 15 | 16 | leftClick(arrow) 17 | expect(wrapper.vm.menu.isOpen).toBe(true) 18 | leftClick(arrow) 19 | expect(wrapper.vm.menu.isOpen).toBe(false) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/utils/scrollIntoView.js: -------------------------------------------------------------------------------- 1 | // from react-select 2 | export function scrollIntoView($scrollingEl, $focusedEl) { 3 | const scrollingReact = $scrollingEl.getBoundingClientRect() 4 | const focusedRect = $focusedEl.getBoundingClientRect() 5 | const overScroll = $focusedEl.offsetHeight / 3 6 | 7 | if (focusedRect.bottom + overScroll > scrollingReact.bottom) { 8 | $scrollingEl.scrollTop = Math.min( 9 | $focusedEl.offsetTop + $focusedEl.clientHeight - $scrollingEl.offsetHeight + overScroll, 10 | $scrollingEl.scrollHeight, 11 | ) 12 | } else if (focusedRect.top - overScroll < scrollingReact.top) { 13 | $scrollingEl.scrollTop = Math.max($focusedEl.offsetTop - overScroll, 0) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/icons/Delete.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /docs/components/CustomizeOptionLabel.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 26 | -------------------------------------------------------------------------------- /docs/partials/introduction.pug: -------------------------------------------------------------------------------- 1 | +section('Introduction') 2 | 3 | section 4 | :markdown-it(html=true) 5 | [vue-treeselect](https://github.com/riophae/vue-treeselect) is a multi-select component with nested options support for [Vue.js](https://www.vuejs.org). 6 | 7 | - Single & multiple select with nested options support 8 | - Fuzzy matching 9 | - Async searching 10 | - Delayed loading (load data of deep level options only when needed) 11 | - Keyboard support (navigate using Arrow Up & Arrow Down keys, select option using Enter key, etc.) 12 | - Rich options & highly customizable 13 | - Supports [a wide range of browsers](https://github.com/riophae/vue-treeselect#browser-compatibility) 14 | 15 | *Requires Vue 2.2+* 16 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | 7 | jobs: 8 | build: 9 | docker: 10 | - image: circleci/node:10-browsers 11 | 12 | working_directory: ~/repo 13 | 14 | steps: 15 | - checkout 16 | 17 | - run: sudo npm install -g npm@latest 18 | 19 | - restore_cache: 20 | key: dependency-cache-{{ checksum "package.json" }} 21 | 22 | - run: npm install 23 | 24 | - save_cache: 25 | paths: 26 | - node_modules 27 | key: dependency-cache-{{ checksum "package.json" }} 28 | 29 | - run: npm test 30 | 31 | - run: ./node_modules/.bin/codecov 32 | 33 | branches: 34 | ignore: 35 | - gh-pages 36 | -------------------------------------------------------------------------------- /docs/components/AsyncSearching.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 32 | -------------------------------------------------------------------------------- /docs/partials/dev.pug: -------------------------------------------------------------------------------- 1 | if NODE_ENV !== 'production' 2 | +section('Dev') 3 | 4 | +subsection('Performance Test') 5 | :markdown-it 6 | Checklist: 7 | - Opening & closing the menu should be fast 8 | - Hovering the mouse over the list should be smooth 9 | - Selecting & deselecting options should feel snappy 10 | +component('PerformanceTest') 11 | 12 | 13 | +subsection('Modal Test') 14 | :markdown-it 15 | Checklist: 16 | - The modal uses `overflow: hidden` and the menu should not be clipped 17 | - When the size of the control changes, the position of the menu should be recalculated 18 | - While scrolling the page, the menu should be kept correctly positioned 19 | +component('ModalTest') 20 | 21 | +subsection('Long Label Test') 22 | +component('LongLabelTest') 23 | -------------------------------------------------------------------------------- /stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'stylelint-config-xo-space', 3 | rules: { 4 | 'string-quotes': [ 'double', { avoidEscape: false } ], 5 | 'declaration-empty-line-before': null, 6 | 'at-rule-empty-line-before': null, 7 | 'selector-list-comma-newline-after': null, 8 | 'rule-empty-line-before': null, 9 | 'value-keyword-case': null, // [ 'lower', { ignoreProperties: [ 'font', 'font-family' ] } ], 10 | 'declaration-block-no-duplicate-properties': [ true, { ignore: [ 'consecutive-duplicates' ] } ], 11 | 'declaration-property-value-blacklist': null, 12 | 'property-blacklist': null, 13 | 'no-unknown-animations': null, 14 | 'font-weight-notation': null, 15 | 'no-descending-specificity': null, 16 | 'selector-max-compound-selectors': null, 17 | 'block-no-empty': [ true, { ignore: [] } ], 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Tip.vue: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 'riophae/vue' ], 4 | plugins: [ 'react' ], 5 | globals: { 6 | PKG_VERSION: true, 7 | }, 8 | settings: { 9 | 'import/resolver': { 10 | node: null, 11 | webpack: { 12 | config: 'build/webpack-configs/base.js', 13 | }, 14 | }, 15 | }, 16 | rules: { 17 | 'import/no-named-as-default': 0, 18 | 'unicorn/consistent-function-scoping': 0, 19 | 'vue/attributes-order': 0, 20 | 'vue/no-v-html': 0, 21 | 'no-confusing-arrow': 0, 22 | 'no-console': 0, 23 | 'no-warning-comments': 0, 24 | 'no-undefined': 0, 25 | 'prefer-destructuring': 0, 26 | }, 27 | overrides: [ { 28 | files: [ 'src/**' ], 29 | rules: { 30 | 'unicorn/no-for-loop': 0, 31 | 'unicorn/prefer-includes': 0, 32 | 'unicorn/prefer-node-append': 0, 33 | }, 34 | } ], 35 | } 36 | -------------------------------------------------------------------------------- /docs/components/VuexSupport.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 47 | -------------------------------------------------------------------------------- /docs/components/Anchor.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /docs/components/dev/ModalTest.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 36 | -------------------------------------------------------------------------------- /docs/components/DisableItemSelection.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 43 | -------------------------------------------------------------------------------- /docs/components/FlatModeAndSortValues.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 35 | -------------------------------------------------------------------------------- /src/components/SingleValue.vue: -------------------------------------------------------------------------------- 1 | 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-present Riophae Lee 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /docs/components/dev/LongLabelTest.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 44 | -------------------------------------------------------------------------------- /src/components/HiddenFields.vue: -------------------------------------------------------------------------------- 1 | 38 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | // ======================== 2 | // Debugging Helpers 3 | // ======================== 4 | 5 | export { warning } from './warning' 6 | 7 | // ======================== 8 | // DOM Utilities 9 | // ======================== 10 | 11 | export { onLeftClick } from './onLeftClick' 12 | export { scrollIntoView } from './scrollIntoView' 13 | export { debounce } from './debounce' 14 | export { watchSize } from './watchSize' 15 | export { setupResizeAndScrollEventListeners } from './setupResizeAndScrollEventListeners' 16 | 17 | // ======================== 18 | // Language Helpers 19 | // ======================== 20 | 21 | export { isNaN } from './isNaN' 22 | export { isPromise } from './isPromise' 23 | export { once } from './once' 24 | export { noop } from './noop' 25 | export { identity } from './identity' 26 | export { constant } from './constant' 27 | export { createMap } from './createMap' 28 | export { deepExtend } from './deepExtend' 29 | export { last } from './last' 30 | export { includes } from './includes' 31 | export { find } from './find' 32 | export { removeFromArray } from './removeFromArray' 33 | 34 | // ======================== 35 | // Other Utilities 36 | // ======================== 37 | 38 | export { quickDiff } from './quickDiff' 39 | -------------------------------------------------------------------------------- /docs/components/utils.js: -------------------------------------------------------------------------------- 1 | import { encodeHTML } from 'entities' 2 | 3 | export const code = str => `${encodeHTML(str)}` 4 | 5 | export const strong = str => `${str}` 6 | 7 | export const link = (target, text = 'here') => `${text}` 8 | 9 | export const makeNameList = names => { 10 | names = names.map(code) 11 | const tail = names.pop() 12 | if (names.length < 2) return tail 13 | return names.join(', ') + ' & ' + tail 14 | } 15 | 16 | export const makePropList = propNames => { 17 | return '{' + propNames.map(code).join(', ') + '}' 18 | } 19 | 20 | export const makeArgNameList = argNames => { 21 | return '(' + argNames.map(code).join(', ') + ')' 22 | } 23 | 24 | function createArray(len, valueMaker) { 25 | const arr = [] 26 | let i = 0 27 | while (i < len) arr.push(valueMaker(i++)) 28 | return arr 29 | } 30 | 31 | export function generateOptions(maxLevel, itemsPerLevel = maxLevel) { 32 | const generate = parentId => createArray(itemsPerLevel, i => { 33 | const id = parentId + String.fromCharCode(97 + i) 34 | const option = { id, label: id.toUpperCase() } 35 | if (id.length < maxLevel) option.children = generate(id) 36 | return option 37 | }) 38 | 39 | return generate('') 40 | } 41 | -------------------------------------------------------------------------------- /test/unit/karma.config.js: -------------------------------------------------------------------------------- 1 | process.env.CHROME_BIN = require('puppeteer').executablePath() 2 | 3 | module.exports = config => { 4 | config.set({ 5 | files: [ './index.js' ], 6 | browsers: [ 'ChromeHeadlessWithoutSandbox' ], 7 | customLaunchers: { 8 | ChromeHeadlessWithoutSandbox: { 9 | // `ChromeHeadless` without any flags used to be working 10 | // well, but it is not now for some unknown reason. 11 | // Adding `--no-sandbox` flag solves the issue, which 12 | // I know is insecure. But since we are only using 13 | // Chrome to run the tests, it should be just fine. 14 | base: 'ChromeHeadless', 15 | flags: [ '--no-sandbox' ], 16 | }, 17 | }, 18 | preprocessors: { 19 | './index.js': [ 'webpack', 'sourcemap' ], 20 | }, 21 | webpack: require('../../build/webpack-configs/test'), 22 | webpackMiddleware: { 23 | noInfo: true, 24 | }, 25 | frameworks: [ 'jasmine', 'jasmine-matchers' ], 26 | client: { 27 | jasmine: { random: false }, 28 | }, 29 | reporters: [ 'spec', 'coverage' ], 30 | coverageReporter: { 31 | dir: './coverage', 32 | reporters: [ 33 | { type: 'lcov', subdir: '.' }, 34 | { type: 'text-summary' }, 35 | ], 36 | }, 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /docs/components/DelayedRootOptions.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 44 | -------------------------------------------------------------------------------- /docs/components/Demo.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 41 | -------------------------------------------------------------------------------- /src/utils/setupResizeAndScrollEventListeners.js: -------------------------------------------------------------------------------- 1 | function findScrollParents($el) { 2 | const $scrollParents = [] 3 | let $parent = $el.parentNode 4 | 5 | while ($parent && $parent.nodeName !== 'BODY' && $parent.nodeType === document.ELEMENT_NODE) { 6 | if (isScrollElment($parent)) $scrollParents.push($parent) 7 | $parent = $parent.parentNode 8 | } 9 | $scrollParents.push(window) 10 | 11 | return $scrollParents 12 | } 13 | 14 | function isScrollElment($el) { 15 | // Firefox wants us to check `-x` and `-y` variations as well 16 | const { overflow, overflowX, overflowY } = getComputedStyle($el) 17 | return /(auto|scroll|overlay)/.test(overflow + overflowY + overflowX) 18 | } 19 | 20 | export function setupResizeAndScrollEventListeners($el, listener) { 21 | const $scrollParents = findScrollParents($el) 22 | 23 | window.addEventListener('resize', listener, { passive: true }) 24 | $scrollParents.forEach(scrollParent => { 25 | scrollParent.addEventListener('scroll', listener, { passive: true }) 26 | }) 27 | 28 | return function removeEventListeners() { 29 | window.removeEventListener('resize', listener, { passive: true }) 30 | $scrollParents.forEach($scrollParent => { 31 | $scrollParent.removeEventListener('scroll', listener, { passive: true }) 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /docs/components/BasicFeatures.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 57 | -------------------------------------------------------------------------------- /docs/components/DocSlots.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 45 | -------------------------------------------------------------------------------- /src/components/MultiValueItem.vue: -------------------------------------------------------------------------------- 1 | 46 | -------------------------------------------------------------------------------- /src/components/Treeselect.vue: -------------------------------------------------------------------------------- 1 | 42 | -------------------------------------------------------------------------------- /test/unit/specs/Slots.spec.js: -------------------------------------------------------------------------------- 1 | // import { mount } from '@vue/test-utils' 2 | // import Treeselect from '@src/components/Treeselect' 3 | // import { findLabelByNodeId } from './shared' 4 | 5 | // Currently @vue/test-utils doesn't properly handle scoped slots. 6 | 7 | // describe('Slots', () => { 8 | // it('option-label', async () => { 9 | // const getLabelText = nodeId => findLabelByNodeId(wrapper, nodeId).element.textContent.trim() 10 | // const wrapper = mount(Treeselect, { 11 | // propsData: { 12 | // options: [ { 13 | // id: 'a', 14 | // label: 'a', 15 | // children: [ { 16 | // id: 'aa', 17 | // label: 'aa', 18 | // } ], 19 | // }, { 20 | // id: 'b', 21 | // label: 'b', 22 | // } ], 23 | // defaultExpandLevel: Infinity, 24 | // }, 25 | // scopedSlots: { 26 | // 'option-label': ` 27 | // 31 | // `, 32 | // }, 33 | // }) 34 | // const { vm } = wrapper 35 | // 36 | // vm.openMenu() 37 | // await vm.$nextTick() 38 | // 39 | // expect(getLabelText('a')).toBe('Branch: a') 40 | // expect(getLabelText('aa')).toBe('Leaf: aa') 41 | // expect(getLabelText('b')).toBe('Branch: b') 42 | // }) 43 | // }) 44 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | // Magic value that indicates a root level node. 2 | export const NO_PARENT_NODE = null 3 | 4 | // Types of checked state. 5 | export const UNCHECKED = 0 6 | export const INDETERMINATE = 1 7 | export const CHECKED = 2 8 | 9 | // Types of count number. 10 | export const ALL_CHILDREN = 'ALL_CHILDREN' 11 | export const ALL_DESCENDANTS = 'ALL_DESCENDANTS' 12 | export const LEAF_CHILDREN = 'LEAF_CHILDREN' 13 | export const LEAF_DESCENDANTS = 'LEAF_DESCENDANTS' 14 | 15 | // Action types of delayed loading. 16 | export const LOAD_ROOT_OPTIONS = 'LOAD_ROOT_OPTIONS' 17 | export const LOAD_CHILDREN_OPTIONS = 'LOAD_CHILDREN_OPTIONS' 18 | export const ASYNC_SEARCH = 'ASYNC_SEARCH' 19 | 20 | // Acceptable values of `valueConsistsOf` prop. 21 | export const ALL = 'ALL' 22 | export const BRANCH_PRIORITY = 'BRANCH_PRIORITY' 23 | export const LEAF_PRIORITY = 'LEAF_PRIORITY' 24 | export const ALL_WITH_INDETERMINATE = 'ALL_WITH_INDETERMINATE' 25 | 26 | // Acceptable values of `sortValueBy` prop. 27 | export const ORDER_SELECTED = 'ORDER_SELECTED' 28 | export const LEVEL = 'LEVEL' 29 | export const INDEX = 'INDEX' 30 | 31 | // Key codes look-up table. 32 | export const KEY_CODES = { 33 | BACKSPACE: 8, 34 | ENTER: 13, 35 | ESCAPE: 27, 36 | END: 35, 37 | HOME: 36, 38 | ARROW_LEFT: 37, 39 | ARROW_UP: 38, 40 | ARROW_RIGHT: 39, 41 | ARROW_DOWN: 40, 42 | DELETE: 46, 43 | } 44 | 45 | // Other constants. 46 | export const INPUT_DEBOUNCE_DELAY = process.env.NODE_ENV === 'testing' 47 | ? /* to speed up unit testing */ 10 48 | : /* istanbul ignore next */ 200 49 | export const MIN_INPUT_WIDTH = 5 50 | export const MENU_BUFFER = 40 51 | -------------------------------------------------------------------------------- /docs/components/DocEvents.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 53 | -------------------------------------------------------------------------------- /docs/components/PreventValueCombining.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 56 | -------------------------------------------------------------------------------- /src/components/MultiValue.vue: -------------------------------------------------------------------------------- 1 | 57 | -------------------------------------------------------------------------------- /test/unit/specs/Events.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { leftClick, findCheckboxByNodeId, findLabelContainerByNodeId } from './shared' 3 | import Treeselect from '@src/components/Treeselect' 4 | 5 | describe('Events', () => { 6 | describe('select & deselect', () => { 7 | let wrapper 8 | 9 | const aa = { 10 | id: 'aa', 11 | label: 'aa', 12 | } 13 | const ab = { 14 | id: 'ab', 15 | label: 'ab', 16 | isDisabled: true, 17 | } 18 | const a = { 19 | id: 'a', 20 | label: 'a', 21 | isDefaultExpanded: true, 22 | children: [ aa, ab ], 23 | } 24 | 25 | beforeEach(() => { 26 | wrapper = mount(Treeselect, { 27 | propsData: { 28 | options: [ a ], 29 | instanceId: 'test', 30 | multiple: true, 31 | value: [ 'ab' ], 32 | }, 33 | }) 34 | wrapper.vm.openMenu() 35 | }) 36 | 37 | it('click on option label or checkbox', () => { 38 | leftClick(findLabelContainerByNodeId(wrapper, 'aa')) 39 | expect(wrapper.emitted().select).toEqual([ 40 | [ aa, 'test' ], 41 | ]) 42 | 43 | leftClick(findCheckboxByNodeId(wrapper, 'aa')) 44 | expect(wrapper.emitted().deselect).toEqual([ 45 | [ aa, 'test' ], 46 | ]) 47 | }) 48 | 49 | it('click on disabled option', () => { 50 | leftClick(findLabelContainerByNodeId(wrapper, 'ab')) 51 | expect(wrapper.emitted().deselect).toBeUndefined() 52 | }) 53 | 54 | it('click on value remove icon', () => { 55 | wrapper.setProps({ value: [ 'a' ] }) 56 | 57 | // click on "×" of a 58 | leftClick(wrapper.find('.vue-treeselect__value-remove')) 59 | expect(wrapper.emitted().deselect).toEqual([ 60 | [ a, 'test' ], 61 | ]) 62 | }) 63 | }) 64 | 65 | // TODO 66 | }) 67 | -------------------------------------------------------------------------------- /src/utils/watchSize.js: -------------------------------------------------------------------------------- 1 | import watchSizeForBrowsersOtherThanIE9 from 'watch-size' 2 | import { removeFromArray } from './removeFromArray' 3 | 4 | let intervalId 5 | const registered = [] 6 | const INTERVAL_DURATION = 100 7 | 8 | function run() { 9 | intervalId = setInterval(() => { 10 | registered.forEach(test) 11 | }, INTERVAL_DURATION) 12 | } 13 | 14 | function stop() { 15 | clearInterval(intervalId) 16 | intervalId = null 17 | } 18 | 19 | function test(item) { 20 | const { $el, listener, lastWidth, lastHeight } = item 21 | const width = $el.offsetWidth 22 | const height = $el.offsetHeight 23 | 24 | if (lastWidth !== width || lastHeight !== height) { 25 | item.lastWidth = width 26 | item.lastHeight = height 27 | 28 | listener({ width, height }) 29 | } 30 | } 31 | 32 | function watchSizeForIE9($el, listener) { 33 | const item = { 34 | $el, 35 | listener, 36 | lastWidth: null, 37 | lastHeight: null, 38 | } 39 | const unwatch = () => { 40 | removeFromArray(registered, item) 41 | if (!registered.length) stop() 42 | } 43 | 44 | registered.push(item) 45 | // The original watch-size will call the listener on initialization. 46 | // Keep the same behavior here. 47 | test(item) 48 | run() 49 | 50 | return unwatch 51 | } 52 | 53 | export function watchSize($el, listener) { 54 | // See: https://stackoverflow.com/a/31293352 55 | const isIE9 = document.documentMode === 9 56 | // watch-size will call the listener on initialization. 57 | // Disable this behavior with a lock to achieve a clearer code logic. 58 | let locked = true 59 | const wrappedListener = (...args) => locked || listener(...args) 60 | const implementation = isIE9 61 | ? watchSizeForIE9 62 | : watchSizeForBrowsersOtherThanIE9 63 | const removeSizeWatcher = implementation($el, wrappedListener) 64 | locked = false // unlock after initialization 65 | 66 | return removeSizeWatcher 67 | } 68 | -------------------------------------------------------------------------------- /test/unit/specs/SearchInput.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { findInput, typeSearchText } from './shared' 3 | import Treeselect from '@src/components/Treeselect' 4 | 5 | describe('Search Input', () => { 6 | it('should disable auto complete', () => { 7 | const wrapper = mount(Treeselect, { 8 | propsData: { 9 | options: [], 10 | }, 11 | }) 12 | const input = findInput(wrapper) 13 | expect(input.element.getAttribute('autocomplete')).toBe('off') 14 | }) 15 | 16 | it('should be unable to focus when disabled=true', () => { 17 | const wrapper = mount(Treeselect, { 18 | propsData: { 19 | options: [], 20 | autoFocus: false, 21 | searchable: true, 22 | disabled: true, 23 | }, 24 | }) 25 | 26 | expect(wrapper.vm.trigger.isFocused).toBe(false) 27 | wrapper.vm.focusInput() 28 | expect(wrapper.vm.trigger.isFocused).toBe(false) 29 | }) 30 | 31 | it('when multiple=true, input should fit the width of user-input text', async () => { 32 | const wrapper = mount(Treeselect, { 33 | propsData: { 34 | options: [], 35 | multiple: true, 36 | searchable: true, 37 | }, 38 | attachToDocument: true, 39 | sync: false, 40 | }) 41 | const input = findInput(wrapper) 42 | const fullText = 'hello world' 43 | let i = 0 44 | let prevWidth = input.element.offsetWidth 45 | 46 | expect(prevWidth).toBeGreaterThan(0) 47 | 48 | while (i < fullText.length) { 49 | await typeSearchText(wrapper, fullText.slice(0, i += 3)) 50 | const width = input.element.offsetWidth 51 | expect(width).toBeGreaterThan(prevWidth) 52 | prevWidth = width 53 | } 54 | 55 | while (i > 0) { 56 | await typeSearchText(wrapper, fullText.slice(0, i -= 3)) 57 | const width = input.element.offsetWidth 58 | expect(width).toBeLessThan(prevWidth) 59 | prevWidth = width 60 | } 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /docs/main.js: -------------------------------------------------------------------------------- 1 | import 'script-loader!../static/prism.min.js' 2 | import 'regenerator-runtime/runtime' 3 | import 'yaku/lib/global' 4 | import Vue from 'vue' 5 | import Treeselect from '../src' 6 | 7 | import './styles/docs.less' 8 | import './styles/prism.less' 9 | 10 | Vue.config.productionTip = false 11 | Vue.component('treeselect', Treeselect) 12 | 13 | let sections 14 | 15 | function calculateNavPositions() { 16 | sections = [].map.call(document.querySelectorAll('[data-section]'), section => ({ 17 | id: section.id, 18 | offset: section.getBoundingClientRect().top + window.pageYOffset - 50, 19 | })) 20 | } 21 | 22 | function loadComponents() { 23 | const loadContext = context => { 24 | context.keys().forEach(key => { 25 | const componentName = key.replace(/^\.\/|\.vue$/g, '') 26 | const component = context(key).default 27 | 28 | Vue.component(componentName, component) 29 | }) 30 | } 31 | 32 | loadContext(require.context('./components', false, /\.vue$/)) 33 | 34 | if (process.env.NODE_ENV !== 'production') { 35 | loadContext(require.context('./components/dev', false, /\.vue$/)) 36 | } 37 | } 38 | loadComponents() 39 | 40 | new Vue({ 41 | el: '#app', 42 | 43 | data: () => ({ 44 | currentPosition: '', 45 | isNavSticky: false, 46 | }), 47 | 48 | mounted() { 49 | this.adjustNav() 50 | window.addEventListener('scroll', this.adjustNav) 51 | setTimeout(calculateNavPositions, 1000) 52 | }, 53 | 54 | methods: { 55 | adjustNav() { 56 | const sidebar = document.getElementById('sidebar') 57 | const scrollTop = document.documentElement.scrollTop || document.body.scrollTop 58 | const offset = sidebar.getBoundingClientRect().top + window.pageYOffset 59 | this.isNavSticky = scrollTop > offset 60 | if (!sections) calculateNavPositions() 61 | for (let i = sections.length - 1; i >= 0; i--) { 62 | if (scrollTop > sections[i].offset || i === 0) { 63 | this.currentPosition = sections[i].id 64 | break 65 | } 66 | } 67 | }, 68 | }, 69 | }) 70 | -------------------------------------------------------------------------------- /docs/mixins.pug: -------------------------------------------------------------------------------- 1 | - function kebabCase(path) { 2 | - return path 3 | - .replace(/ & /g, ' And ') 4 | - .replace(/[A-Z]+/, x => x[0] + x.slice(1).toLowerCase()) 5 | - .replace(/^[A-Z]/, x => x.toLowerCase()) 6 | - .replace(/[A-Z ]/g, x => '-' + x.toLowerCase()) 7 | - .replace(/[^A-Za-z]/g, '-') 8 | - .replace(/(^-+|-+$)/g, '') 9 | - .replace(/-{2,}/g, '-') 10 | - } 11 | - function startCase(name) { 12 | - return name.replace(/\s/g, '') 13 | - } 14 | - function loadSFCSource(name, type) { 15 | - const stripIndent = require('strip-indent') 16 | - const source = require(`!raw-loader!./components/${name}.vue`).default 17 | - const startTag = `<${type}>` 18 | - const endTag = `` 19 | - const start = source.indexOf(startTag) + startTag.length 20 | - const end = source.indexOf(endTag) 21 | - return stripIndent(source.slice(start, end).replace(/^\n+/, '')) 22 | - } 23 | - const toc = [] 24 | 25 | mixin section(name) 26 | - toc.push({ name, subs: [] }) 27 | h1(id=kebabCase(name)) 28 | =name 29 | a.anchor(href='#' + kebabCase(name)) 30 | anchor 31 | 32 | mixin subsection(name) 33 | - toc[toc.length - 1].subs.push(name) 34 | section 35 | h2(id=kebabCase(name) data-section) 36 | =name 37 | a.anchor(href='#' + kebabCase(name)) 38 | anchor 39 | block 40 | 41 | mixin component(name) 42 | #{kebabCase(name)} 43 | 44 | mixin demo(name) 45 | demo 46 | #{kebabCase(name)}(slot="demo") 47 | template(slot="html") 48 | code(v-pre)=loadSFCSource(name, 'template') 49 | template(slot="javascript") 50 | code(v-pre)=loadSFCSource(name, 'script') 51 | 52 | mixin renderToc() 53 | - const tocSwapBuffer = pug_html 54 | - pug_html = '' 55 | for section in toc 56 | section.sidebar-nav-section 57 | h4.sidebar-nav-section-title=section.name 58 | ul.sidebar-nav-list 59 | for sub in section.subs 60 | - const customClass = "{ current: currentPosition === '" + kebabCase(sub) + "' }" 61 | li.sidebar-nav-list-item(:class=customClass) 62 | a(href='#' + kebabCase(sub))=sub 63 | - pug_html = tocSwapBuffer.replace('%%_TOC_%%', pug_html) 64 | -------------------------------------------------------------------------------- /docs/styles/prism.less: -------------------------------------------------------------------------------- 1 | @import "~material-colors/dist/colors.less"; 2 | 3 | /** 4 | * prism.js default theme for JavaScript, CSS and HTML 5 | * Based on dabblet (http://dabblet.com) 6 | * Modified by Riophae Lee 7 | * @author Lea Verou 8 | */ 9 | 10 | code[class*="language-"], 11 | pre[class*="language-"] { 12 | box-sizing: border-box; 13 | background: none; 14 | text-align: left; 15 | white-space: pre; 16 | word-spacing: normal; 17 | word-break: normal; 18 | word-wrap: normal; 19 | line-height: 1.5; 20 | font-size: 12px; 21 | tab-size: 2; 22 | hyphens: none; 23 | } 24 | 25 | /* Code blocks */ 26 | pre[class*="language-"] { 27 | padding: 1.2em 1.4em; 28 | margin: 1.2em 0; 29 | overflow: auto; 30 | background: #f8f8f8; 31 | border-radius: 5px; 32 | } 33 | 34 | /* Inline code */ 35 | :not(pre) > code[class*="language-"] { 36 | padding: 0.1em; 37 | border-radius: 0.3em; 38 | white-space: normal; 39 | } 40 | 41 | .token.comment, 42 | .token.prolog, 43 | .token.doctype, 44 | .token.cdata { 45 | color: @md-blue-grey-300; 46 | } 47 | 48 | .token.punctuation { 49 | color: #999; 50 | } 51 | 52 | .namespace { 53 | opacity: 0.7; 54 | } 55 | 56 | .token.property, 57 | .token.tag, 58 | .token.boolean, 59 | .token.number, 60 | .token.constant, 61 | .token.symbol, 62 | .token.deleted { 63 | color: @md-light-blue-800; 64 | } 65 | 66 | .token.selector, 67 | .token.attr-name, 68 | .token.string, 69 | .token.char, 70 | .token.builtin, 71 | .token.inserted { 72 | color: @md-green-700; 73 | } 74 | 75 | .token.operator, 76 | .token.entity, 77 | .token.url, 78 | .language-css .token.string, 79 | .style .token.string { 80 | color: #a67f59; 81 | } 82 | 83 | .token.atrule, 84 | .token.attr-value, 85 | .token.keyword { 86 | color: @md-orange-900; 87 | } 88 | 89 | .token.function { 90 | color: @md-red-900; 91 | } 92 | 93 | .token.regex, 94 | .token.important, 95 | .token.variable { 96 | color: #e90; 97 | } 98 | 99 | .token.important, 100 | .token.bold { 101 | font-weight: bold; 102 | } 103 | 104 | .token.italic { 105 | font-style: italic; 106 | } 107 | 108 | .token.entity { 109 | cursor: help; 110 | } 111 | -------------------------------------------------------------------------------- /docs/components/DelayedLoading.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 77 | -------------------------------------------------------------------------------- /docs/components/NestedSearch.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 82 | -------------------------------------------------------------------------------- /docs/components/MoreFeatures.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 76 | -------------------------------------------------------------------------------- /test/unit/specs/HiddenFields.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import Treeselect from '@src/components/Treeselect' 3 | 4 | describe('Hidden Fields', () => { 5 | let wrapper 6 | 7 | beforeEach(() => { 8 | wrapper = mount(Treeselect, { 9 | propsData: { 10 | options: [], 11 | }, 12 | }) 13 | }) 14 | 15 | const getHiddenFields = () => wrapper.findAll('input[type="hidden"]') 16 | 17 | it('must have value & name', () => { 18 | wrapper.setProps({ value: 'value' }) 19 | expect(getHiddenFields().length).toBe(0) 20 | 21 | wrapper.setProps({ value: null, name: 'test' }) 22 | expect(getHiddenFields().length).toBe(0) 23 | 24 | wrapper.setProps({ value: 'value', name: 'test' }) 25 | expect(getHiddenFields().length).not.toBe(0) 26 | }) 27 | 28 | it('single-select mode', () => { 29 | wrapper.setProps({ name: 'single', value: 'value' }) 30 | const hiddenFields = getHiddenFields() 31 | expect(hiddenFields.length).toBe(1) 32 | expect(hiddenFields.at(0).html()).toBe('') 33 | }) 34 | 35 | it('multi-select mode', () => { 36 | wrapper.setProps({ name: 'multiple', multiple: true, value: [ 1, 2, 3 ] }) 37 | const hiddenFields = getHiddenFields() 38 | expect(hiddenFields.length).toBe(3) 39 | expect(hiddenFields.wrappers.map(hf => hf.html())).toEqual([ 40 | '', 41 | '', 42 | '', 43 | ]) 44 | }) 45 | 46 | it('join values', () => { 47 | wrapper.setProps({ name: 'join-values', multiple: true, value: [ 'a', 'b', 'c' ], joinValues: true }) 48 | const hiddenFields = getHiddenFields() 49 | expect(hiddenFields.length).toBe(1) 50 | expect(hiddenFields.at(0).html()).toBe('') 51 | }) 52 | 53 | it('delimiter', async () => { 54 | wrapper.setProps({ name: 'delimiter', multiple: true, value: [ 1, 2, 3 ], joinValues: true, delimiter: ';' }) 55 | await wrapper.vm.$nextTick() 56 | const hiddenFields = getHiddenFields() 57 | expect(hiddenFields.length).toBe(1) 58 | expect(hiddenFields.at(0).html()).toBe('') 59 | }) 60 | 61 | it('disabled', async () => { 62 | wrapper.setProps({ name: 'disabled', value: 'value', disabled: true }) 63 | await wrapper.vm.$nextTick() 64 | const hiddenFields = getHiddenFields() 65 | expect(hiddenFields.length).toBe(0) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /docs/components/DocNode.vue: -------------------------------------------------------------------------------- 1 | 49 | -------------------------------------------------------------------------------- /docs/partials/getting-started.pug: -------------------------------------------------------------------------------- 1 | +section('Getting Started') 2 | 3 | section 4 | :markdown-it 5 | It's recommended to install vue-treeselect via npm, and build your app using a bundler like [webpack](https://webpack.js.org/). 6 | 7 | ```bash 8 | npm install --save @riophae/vue-treeselect 9 | ``` 10 | 11 | :markdown-it 12 | This example shows how to integrate vue-treeselect with your [Vue SFCs](https://vuejs.org/v2/guide/single-file-components.html). 13 | 14 | ```vue 15 | 16 | 21 | 22 | 57 | ``` 58 | 59 | If you just don't want to use webpack or any other bundlers, you can simply include the standalone UMD build in your page. In this way, make sure Vue as a dependency is included before vue-treeselect. 60 | 61 | ```html 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 |
72 | 73 |
74 | 75 | 105 | 106 | ``` 107 | -------------------------------------------------------------------------------- /docs/index.pug: -------------------------------------------------------------------------------- 1 | include ./mixins 2 | 3 | doctype html 4 | html(lang="en") 5 | head 6 | meta(charset="utf-8") 7 | meta(name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0") 8 | title Vue-Treeselect 9 | meta(name="description" content="A multi-select component with nested options support for Vue.js") 10 | meta(property="og:title" content="Vue-Treeselect") 11 | meta(property="og:site_name" content="Vue-Treeselect") 12 | meta(property="og:url" content="https://vue-treeselect.js.org/") 13 | meta(property="og:description" content="A multi-select component with nested options support for Vue.js") 14 | meta(property="og:image" content="https://vue-treeselect.js.org/static/vue-logo.png") 15 | meta(property="twitter:title" content="Vue-Treeselect") 16 | meta(property="twitter:description" content="A multi-select component with nested options support for Vue.js") 17 | meta(property="twitter:image" content="https://vue-treeselect.js.org/static/vue-logo.png") 18 | link(rel="apple-touch-icon" sizes="57x57" href="/static/images/icons/apple-icon-57x57.png") 19 | link(rel="apple-touch-icon" sizes="60x60" href="/static/images/icons/apple-icon-60x60.png") 20 | link(rel="apple-touch-icon" sizes="72x72" href="/static/images/icons/apple-icon-72x72.png") 21 | link(rel="apple-touch-icon" sizes="76x76" href="/static/images/icons/apple-icon-76x76.png") 22 | link(rel="apple-touch-icon" sizes="114x114" href="/static/images/icons/apple-icon-114x114.png") 23 | link(rel="apple-touch-icon" sizes="120x120" href="/static/images/icons/apple-icon-120x120.png") 24 | link(rel="apple-touch-icon" sizes="144x144" href="/static/images/icons/apple-icon-144x144.png") 25 | link(rel="apple-touch-icon" sizes="152x152" href="/static/images/icons/apple-icon-152x152.png") 26 | link(rel="apple-touch-icon" sizes="180x180" href="/static/images/icons/apple-icon-180x180.png") 27 | link(rel="icon" type="image/png" sizes="192x192" href="/static/images/icons/android-icon-192x192.png") 28 | link(rel="icon" type="image/png" sizes="32x32" href="/static/images/icons/favicon-32x32.png") 29 | link(rel="icon" type="image/png" sizes="96x96" href="/static/images/icons/favicon-96x96.png") 30 | link(rel="icon" type="image/png" sizes="16x16" href="/static/images/icons/favicon-16x16.png") 31 | meta(name="msapplication-TileImage" content="/static/images/icons/ms-icon-144x144.png") 32 | link(rel="icon" href="/static/vue-logo.png" type="image/png") 33 | meta(name="msapplication-TileColor" content="#4fc08d") 34 | meta(name="theme-color" content="#4fc08d") 35 | meta(name="msapplication-config" content="browserconfig.xml") 36 | if NODE_ENV === 'production' 37 | // Global site tag (gtag.js) - Google Analytics 38 | script(async src="https://www.googletagmanager.com/gtag/js?id=UA-108169693-1") 39 | script. 40 | window.dataLayer = window.dataLayer || []; 41 | function gtag(){dataLayer.push(arguments);} 42 | gtag('js', new Date()); 43 | gtag('config', 'UA-108169693-1'); 44 | link(rel="stylesheet" type="text/css" href="//fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,600|Dosis:300,500") 45 | body 46 | #app 47 | header.site-header 48 | div#header.container 49 | div.site-header-logo 50 | a.site-header-logo-link(href="https://vue-treeselect.js.org/") 51 | | Vue 52 | span.site-header-logo-component-name Treeselect 53 | nav.site-header-nav 54 | a.site-header-nav-item(href="https://github.com/riophae/vue-treeselect/releases") 55 | =`v${require('../package.json').version}` 56 | span.site-header-nav-item 57 | a.github-button(href="https://github.com/riophae/vue-treeselect" data-show-count="true" aria-label="Star riophae/vue-treeselect on GitHub") 58 | | Star 59 | div.container 60 | section#main 61 | div#sidebar 62 | include ./partials/sidebar 63 | div#content 64 | include ./partials/dev 65 | include ./partials/introduction 66 | include ./partials/getting-started 67 | include ./partials/guides 68 | include ./partials/api 69 | footer.site-footer 70 | div.container 71 | | Copyright © 2017-#{new Date().getFullYear()} Riophae Lee. MIT Licensed. 72 | script(src="https://buttons.github.io/buttons.js" async defer) 73 | 74 | +renderToc() 75 | -------------------------------------------------------------------------------- /src/components/Control.vue: -------------------------------------------------------------------------------- 1 | 154 | -------------------------------------------------------------------------------- /test/unit/specs/Menu.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { 3 | generateOptions, 4 | leftClick, typeSearchText, 5 | findMenu, findOptionByNodeId, 6 | } from './shared' 7 | import Treeselect from '@src/components/Treeselect' 8 | import Input from '@src/components/Input' 9 | 10 | describe('Menu', () => { 11 | it('should blur the input & close the menu after clicking anywhere outside the component', async () => { 12 | const wrapper = mount(Treeselect, { 13 | sync: false, 14 | attachToDocument: true, 15 | propsData: { 16 | options: [], 17 | }, 18 | }) 19 | const { vm } = wrapper 20 | 21 | vm.openMenu() 22 | await vm.$nextTick() 23 | 24 | const event = document.createEvent('event') 25 | event.initEvent('mousedown', true, true) 26 | document.body.dispatchEvent(event) 27 | expect(vm.trigger.isFocused).toBe(false) 28 | expect(vm.menu.isOpen).toBe(false) 29 | }) 30 | 31 | it('should open the menu after clicking the control when focused', () => { 32 | const wrapper = mount(Treeselect, { 33 | attachToDocument: true, 34 | propsData: { 35 | options: [], 36 | }, 37 | }) 38 | wrapper.vm.trigger.isFocused = true 39 | const valueContainer = wrapper.find('.vue-treeselect__value-container') 40 | 41 | leftClick(valueContainer) 42 | expect(wrapper.vm.menu.isOpen).toBe(true) 43 | }) 44 | 45 | it('click on option arrow should toggle expanded', async () => { 46 | const wrapper = mount(Treeselect, { 47 | sync: false, 48 | attachToDocument: true, 49 | propsData: { 50 | options: [ { 51 | id: 'a', 52 | label: 'a', 53 | children: [], 54 | } ], 55 | }, 56 | }) 57 | const { vm } = wrapper 58 | const { a } = vm.forest.nodeMap 59 | 60 | vm.openMenu() 61 | await vm.$nextTick() 62 | 63 | expect(a.isExpanded).toBe(false) 64 | const optionArrow = findOptionByNodeId(wrapper, 'a').find('.vue-treeselect__option-arrow-container') 65 | leftClick(optionArrow) 66 | expect(a.isExpanded).toBe(true) 67 | leftClick(optionArrow) 68 | expect(a.isExpanded).toBe(false) 69 | }) 70 | 71 | it('should highlight the option when the cursor hovering over it', async () => { 72 | const wrapper = mount(Treeselect, { 73 | propsData: { 74 | options: [ { 75 | id: 'a', 76 | label: 'a', 77 | }, { 78 | id: 'b', 79 | label: 'b', 80 | } ], 81 | }, 82 | }) 83 | const { vm } = wrapper 84 | 85 | vm.openMenu() 86 | await vm.$nextTick() 87 | 88 | expect(vm.menu.current).toBe('a') 89 | expect(vm.forest.nodeMap.a.isHighlighted).toBe(true) 90 | 91 | findOptionByNodeId(wrapper, 'b').trigger('mouseenter') 92 | expect(vm.menu.current).toBe('b') 93 | expect(vm.forest.nodeMap.b.isHighlighted).toBe(true) 94 | 95 | findOptionByNodeId(wrapper, 'a').trigger('mouseenter') 96 | expect(vm.menu.current).toBe('a') 97 | expect(vm.forest.nodeMap.a.isHighlighted).toBe(true) 98 | }) 99 | 100 | it('retain scroll position on menu reopen', async () => { 101 | const maxHeight = 100 102 | const wrapper = mount(Treeselect, { 103 | propsData: { 104 | options: generateOptions(3), 105 | defaultExpandLevel: Infinity, 106 | maxHeight, 107 | }, 108 | attachToDocument: true, 109 | }) 110 | const { vm } = wrapper 111 | 112 | let i = 3 113 | let pos = 0 114 | const step = 20 115 | while (i--) { 116 | vm.openMenu() 117 | await vm.$nextTick() 118 | const menu = findMenu(wrapper) 119 | expect(menu.element.scrollHeight).toBeGreaterThan(maxHeight) 120 | expect(menu.element.scrollTop).toBe(pos) 121 | pos += step 122 | menu.element.scrollBy(0, step) 123 | vm.closeMenu() 124 | await vm.$nextTick() 125 | } 126 | }) 127 | 128 | it('should reset the search box after closing menu', async () => { 129 | const wrapper = mount(Treeselect, { 130 | sync: false, 131 | propsData: { 132 | options: [], 133 | }, 134 | }) 135 | const { vm } = wrapper 136 | const input = wrapper.find(Input) 137 | 138 | const assertInputValue = expected => { 139 | expect(vm.trigger.searchQuery).toBe(expected) 140 | expect(input.vm.value).toBe(expected) 141 | expect(input.find('input[type="text"]').element.value).toBe(expected) 142 | } 143 | 144 | vm.openMenu() 145 | await vm.$nextTick() 146 | 147 | await typeSearchText(wrapper, 'a') 148 | assertInputValue('a') 149 | 150 | vm.closeMenu() 151 | await vm.$nextTick() 152 | assertInputValue('') 153 | }) 154 | 155 | it('set appendToBody to true when alwaysOpen=true should not throw error', () => { 156 | const wrapper = mount(Treeselect, { 157 | attachToDocument: true, 158 | propsData: { 159 | options: [], 160 | alwaysOpen: true, 161 | }, 162 | }) 163 | 164 | wrapper.setProps({ appendToBody: true }) 165 | }) 166 | }) 167 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@riophae/vue-treeselect", 3 | "version": "0.4.0", 4 | "description": "A multi-select component with nested options support for Vue.js", 5 | "author": "Riophae Lee ", 6 | "license": "MIT", 7 | "homepage": "https://vue-treeselect.js.org/", 8 | "repository": "riophae/vue-treeselect", 9 | "main": "dist/vue-treeselect.cjs.js", 10 | "unpkg": "dist/vue-treeselect.umd.min.js", 11 | "css": "dist/vue-treeselect.min.css", 12 | "files": [ 13 | "src", 14 | "dist" 15 | ], 16 | "scripts": { 17 | "dev": "node build/dev-server.js", 18 | "build-library": "node build/build-library.js", 19 | "build-docs": "rimraf gh-pages && mkdir gh-pages && node build/build-docs.js", 20 | "preview-docs": "http-server gh-pages", 21 | "gh-pages": "npm run build-docs && gh-pages --dist gh-pages --branch gh-pages --dotfiles", 22 | "cleanup-cov": "rimraf test/unit/coverage", 23 | "unit": "npm run cleanup-cov && karma start test/unit/karma.conf.js --watch", 24 | "testonly": "npm run cleanup-cov && karma start test/unit/karma.config.js --single-run", 25 | "test": "npm run testonly", 26 | "pretest": "npm run lint", 27 | "lint:js": "eslint --ext .js --ext .vue --cache --cache-location node_modules/.cache/eslint --rule 'no-console: 2' .", 28 | "lint:css": "stylelint '**/*.less'", 29 | "lint": "npm run lint:js && npm run lint:css", 30 | "verify-builds": "size-limit && node build/verify-builds.js", 31 | "finish": "npm test && npm run build-library && npm run verify-builds" 32 | }, 33 | "pre-commit": "lint", 34 | "dependencies": { 35 | "@babel/runtime": "^7.3.1", 36 | "babel-helper-vue-jsx-merge-props": "^2.0.3", 37 | "easings-css": "^1.0.0", 38 | "fuzzysearch": "^1.0.3", 39 | "is-promise": "^2.1.0", 40 | "lodash": "^4.0.0", 41 | "material-colors": "^1.2.6", 42 | "watch-size": "^2.0.0" 43 | }, 44 | "devDependencies": { 45 | "@babel/core": "^7.0.0", 46 | "@babel/plugin-syntax-jsx": "^7.0.0", 47 | "@babel/plugin-transform-runtime": "^7.0.0", 48 | "@babel/preset-env": "^7.3.1", 49 | "@size-limit/preset-big-lib": "^2.0.2", 50 | "@vue/test-utils": "1.0.0-beta.16", 51 | "autoprefixer": "^9.4.6", 52 | "babel-eslint": "^10.0.1", 53 | "babel-loader": "^8.0.0", 54 | "babel-plugin-istanbul": "^5.0.1", 55 | "babel-plugin-transform-vue-jsx": "^4.0.1", 56 | "cache-loader": "^4.0.1", 57 | "chalk": "^2.3.2", 58 | "codecov": "^3.0.0", 59 | "connect-history-api-fallback": "^1.5.0", 60 | "copy-webpack-plugin": "^5.0.3", 61 | "css-loader": "^3.0.0", 62 | "entities": "^2.0.0", 63 | "eslint": "^6.1.0", 64 | "eslint-config-riophae": "^0.10.0", 65 | "eslint-friendly-formatter": "^4.0.0", 66 | "eslint-import-resolver-webpack": "^0.11.0", 67 | "eslint-loader": "^3.0.0", 68 | "eslint-plugin-import": "^2.15.0", 69 | "eslint-plugin-react": "^7.10.0", 70 | "eslint-plugin-unicorn": "^12.1.0", 71 | "eslint-plugin-vue": "^6.0.0", 72 | "eventsource-polyfill": "^0.9.6", 73 | "express": "^4.16.3", 74 | "friendly-errors-webpack-plugin": "^1.7.0", 75 | "gh-pages": "^2.0.0", 76 | "html-webpack-plugin": "^3.1.0", 77 | "http-server": "^0.11.1", 78 | "jasmine-core": "^3.1.0", 79 | "jstransformer-markdown-it": "^2.0.0", 80 | "karma": "^4.0.0", 81 | "karma-chrome-launcher": "^3.0.0", 82 | "karma-coverage": "^2.0.1", 83 | "karma-jasmine": "^2.0.0", 84 | "karma-jasmine-matchers": "^4.0.1", 85 | "karma-sourcemap-loader": "^0.3.7", 86 | "karma-spec-reporter": "0.0.32", 87 | "karma-webpack": "^4.0.2", 88 | "less": "^3.0.1", 89 | "less-loader": "^5.0.0", 90 | "mini-css-extract-plugin": "^0.8.0", 91 | "open": "^7.0.0", 92 | "optimize-css-assets-webpack-plugin": "^5.0.0", 93 | "ora": "^4.0.1", 94 | "postcss-loader": "^3.0.0", 95 | "pre-commit": "^1.2.2", 96 | "pug": "^2.0.0", 97 | "pug-loader": "^2.4.0", 98 | "puppeteer": "^2.0.0", 99 | "raw-loader": "^3.0.0", 100 | "regenerator-runtime": "^0.13.1", 101 | "rimraf": "^3.0.0", 102 | "run-series": "^1.1.8", 103 | "script-loader": "^0.7.2", 104 | "shallow-equal": "^1.0.0", 105 | "shelljs": "^0.8.1", 106 | "string.prototype.repeat": "^0.2.0", 107 | "strip-indent": "^3.0.0", 108 | "stylelint": "^11.0.0", 109 | "stylelint-config-xo-space": "^0.13.0", 110 | "terser-webpack-plugin": "^2.1.0", 111 | "url-loader": "^2.0.1", 112 | "vodal": "^2.3.3", 113 | "vue": "^2.2.0", 114 | "vue-loader": "^15.6.0", 115 | "vue-style-loader": "^4.0.2", 116 | "vue-template-compiler": "^2.4.4", 117 | "vuex": "^3.0.1", 118 | "webpack": "^4.6.0", 119 | "webpack-bundle-analyzer": "^3.0.2", 120 | "webpack-cdn-plugin": "^3.1.4", 121 | "webpack-dev-middleware": "^3.1.0", 122 | "webpack-hot-middleware": "^2.18.0", 123 | "webpack-merge": "^4.1.0", 124 | "webpack-node-externals": "^1.7.2", 125 | "yaku": "^0.19.3" 126 | }, 127 | "peerDependencies": { 128 | "vue": "^2.2.0" 129 | }, 130 | "keywords": [ 131 | "vue", 132 | "component", 133 | "tree", 134 | "treeview", 135 | "select", 136 | "dropdown", 137 | "treeselect", 138 | "multiselect", 139 | "form", 140 | "control", 141 | "input", 142 | "ui" 143 | ] 144 | } 145 | -------------------------------------------------------------------------------- /test/unit/specs/shared.js: -------------------------------------------------------------------------------- 1 | import sleep from 'yaku/lib/sleep' 2 | import Option from '@src/components/Option' 3 | import { INPUT_DEBOUNCE_DELAY } from '@src/constants' 4 | 5 | export function $(selector, context = document) { 6 | return context.querySelector(selector) 7 | } 8 | 9 | function createArray(len, fn) { 10 | const arr = [] 11 | let i = 0 12 | while (i < len) arr.push(fn(i++)) 13 | return arr 14 | } 15 | 16 | export function generateOptions(maxLevel) { 17 | const generate = (i, level) => { 18 | const id = String.fromCharCode(97 + i).repeat(level) 19 | const option = { id, label: id.toUpperCase() } 20 | if (level < maxLevel) option.children = [ generate(i, level + 1) ] 21 | return option 22 | } 23 | 24 | return createArray(maxLevel, i => generate(i, 1)) 25 | } 26 | 27 | function createKeyObject(keyCode) { 28 | return { which: keyCode, keyCode } 29 | } 30 | 31 | export function leftClick(wrapper) { 32 | const MOUSE_BUTTON_LEFT = { button: 0 } 33 | wrapper.trigger('mousedown', MOUSE_BUTTON_LEFT) 34 | } 35 | 36 | export function pressBackspaceKey(wrapper) { 37 | const input = findInput(wrapper) 38 | const KEY_BACKSPACE = createKeyObject(8) 39 | input.trigger('keydown', KEY_BACKSPACE) 40 | } 41 | 42 | export function pressEnterKey(wrapper) { 43 | const input = findInput(wrapper) 44 | const KEY_ENTER = createKeyObject(13) 45 | input.trigger('keydown', KEY_ENTER) 46 | } 47 | 48 | export function pressEscapeKey(wrapper, modifierKey) { 49 | const input = findInput(wrapper) 50 | const KEY_ESCAPE = createKeyObject(27) 51 | let eventData = KEY_ESCAPE 52 | if (modifierKey) eventData = { ...KEY_ESCAPE, [modifierKey]: true } 53 | input.trigger('keydown', eventData) 54 | } 55 | 56 | export function pressEndKey(wrapper) { 57 | const input = findInput(wrapper) 58 | const KEY_END = createKeyObject(35) 59 | input.trigger('keydown', KEY_END) 60 | } 61 | 62 | export function pressHomeKey(wrapper) { 63 | const input = findInput(wrapper) 64 | const KEY_HOME = createKeyObject(36) 65 | input.trigger('keydown', KEY_HOME) 66 | } 67 | 68 | export function pressArrowLeft(wrapper) { 69 | const input = findInput(wrapper) 70 | const ARROW_LEFT = createKeyObject(37) 71 | input.trigger('keydown', ARROW_LEFT) 72 | } 73 | 74 | export function pressArrowUp(wrapper) { 75 | const input = findInput(wrapper) 76 | const ARROW_UP = createKeyObject(38) 77 | input.trigger('keydown', ARROW_UP) 78 | } 79 | 80 | export function pressArrowRight(wrapper) { 81 | const input = findInput(wrapper) 82 | const ARROW_RIGHT = createKeyObject(39) 83 | input.trigger('keydown', ARROW_RIGHT) 84 | } 85 | 86 | export function pressArrowDown(wrapper) { 87 | const input = findInput(wrapper) 88 | const ARROW_DOWN = createKeyObject(40) 89 | input.trigger('keydown', ARROW_DOWN) 90 | } 91 | 92 | export function pressDeleteKey(wrapper) { 93 | const input = findInput(wrapper) 94 | const KEY_DELETE = createKeyObject(46) 95 | input.trigger('keydown', KEY_DELETE) 96 | } 97 | 98 | export function pressAKey(wrapper) { 99 | const input = findInput(wrapper) 100 | const KEY_A = createKeyObject(65) 101 | input.trigger('keydown', KEY_A) 102 | } 103 | 104 | export async function typeSearchText(wrapper, text) { 105 | const $input = findInput(wrapper) 106 | $input.element.value = text 107 | $input.trigger('input') 108 | expect(wrapper.vm.$refs.control.$refs['value-container'].$refs.input.value).toBe(text) 109 | await sleep(INPUT_DEBOUNCE_DELAY + 1) 110 | expect(wrapper.vm.trigger.searchQuery).toBe(text) 111 | } 112 | 113 | export function findInputContainer(wrapper) { 114 | return wrapper.find('.vue-treeselect__input-container') 115 | } 116 | 117 | export function findInput(wrapper) { 118 | return wrapper.find('.vue-treeselect__input') 119 | } 120 | 121 | export function findMenuContainer(wrapper) { 122 | return wrapper.find('.vue-treeselect__menu-container') 123 | } 124 | 125 | export function findMenu(wrapper) { 126 | return wrapper.find('.vue-treeselect__menu') 127 | } 128 | 129 | export function findVisibleOptions(wrapper) { 130 | return wrapper.findAll('.vue-treeselect__option:not(.vue-treeselect__option--hide)') 131 | } 132 | 133 | export function findOptionByNodeId(wrapper, nodeId) { 134 | return wrapper.find(`.vue-treeselect__option[data-id="${nodeId}"]`) 135 | } 136 | 137 | export function findOptionArrowContainerByNodeId(wrapper, nodeId) { 138 | return findOptionByNodeId(wrapper, nodeId).find('.vue-treeselect__option-arrow-container') 139 | } 140 | 141 | export function findOptionArrowByNodeId(wrapper, nodeId) { 142 | return findOptionByNodeId(wrapper, nodeId).find('.vue-treeselect__option-arrow') 143 | } 144 | 145 | export function findCheckboxByNodeId(wrapper, nodeId) { 146 | return findOptionByNodeId(wrapper, nodeId).find('.vue-treeselect__checkbox') 147 | } 148 | 149 | export function findLabelContainerByNodeId(wrapper, nodeId) { 150 | return findOptionByNodeId(wrapper, nodeId).find('.vue-treeselect__label-container') 151 | } 152 | 153 | export function findLabelByNodeId(wrapper, nodeId) { 154 | return findOptionByNodeId(wrapper, nodeId).find('.vue-treeselect__label') 155 | } 156 | 157 | export function findChildrenOptionListByNodeId(wrapper, nodeId) { 158 | return wrapper.findAll(Option).wrappers 159 | .find(optionWrapper => optionWrapper.vm.node.id === nodeId) 160 | .find('.vue-treeselect__list') 161 | } 162 | -------------------------------------------------------------------------------- /src/components/MenuPortal.vue: -------------------------------------------------------------------------------- 1 | 180 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-treeselect 2 | [![npm](https://badgen.now.sh/npm/v/@riophae/vue-treeselect)](https://www.npmjs.com/package/@riophae/vue-treeselect) [![Build](https://badgen.now.sh/circleci/github/riophae/vue-treeselect)](https://circleci.com/gh/riophae/vue-treeselect/tree/master) [![Coverage](https://badgen.net/codecov/c/github/riophae/vue-treeselect)](https://codecov.io/gh/riophae/vue-treeselect?branch=master) 3 | ![npm monthly downloads](https://badgen.now.sh/npm/dm/@riophae/vue-treeselect) 4 | ![jsDelivr monthly hits](https://badgen.net/jsdelivr/hits/npm/@riophae/vue-treeselect) [![Known vulnerabilities](https://snyk.io/test/npm/@riophae/vue-treeselect/badge.svg)](https://snyk.io/test/npm/@riophae/vue-treeselect) ![License](https://badgen.net/github/license/riophae/vue-treeselect) 5 | 6 | > A multi-select component with nested options support for Vue.js 7 | 8 | ![Vue-Treeselect Screenshot](https://raw.githubusercontent.com/riophae/vue-treeselect/master/screenshot.png) 9 | 10 | ### Features 11 | 12 | - Single & multiple select with nested options support 13 | - Fuzzy matching 14 | - Async searching 15 | - Delayed loading (load data of deep level options only when needed) 16 | - Keyboard support (navigate using Arrow Up & Arrow Down keys, select option using Enter key, etc.) 17 | - Rich options & highly customizable 18 | - Supports a wide range of browsers (see [below](#browser-compatibility)) 19 | - RTL support 20 | 21 | *Requires Vue 2.2+* 22 | 23 | ### Getting Started 24 | 25 | It's recommended to install vue-treeselect via npm, and build your app using a bundler like [webpack](https://webpack.js.org/). 26 | 27 | ```bash 28 | npm install --save @riophae/vue-treeselect 29 | ``` 30 | 31 | This example shows how to integrate vue-treeselect with your [Vue SFCs](https://vuejs.org/v2/guide/single-file-components.html). 32 | 33 | ```vue 34 | 35 | 40 | 41 | 76 | ``` 77 | 78 | If you just don't want to use webpack or any other bundlers, you can simply include the standalone UMD build in your page. In this way, make sure Vue as a dependency is included before vue-treeselect. 79 | 80 | ```html 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | 94 | 124 | 125 | ``` 126 | 127 | ### Documentation & Live Demo 128 | 129 | [Visit the website](https://vue-treeselect.js.org/) 130 | 131 | Note: please use a desktop browser since the website hasn't been optimized for mobile devices. 132 | 133 | ### Browser Compatibility 134 | 135 | - Chrome 136 | - Edge 137 | - Firefox 138 | - IE ≥ 9 139 | - Safari 140 | 141 | It should function well on IE9, but the style can be slightly broken due to the lack of support of some relatively new CSS features, such as `transition` and `animation`. Nevertheless it should look 90% same as on modern browsers. 142 | 143 | ### Bugs 144 | 145 | You can use this [pen](https://codepen.io/riophae/pen/MExgzP) to reproduce bugs and then [open an issue](https://github.com/riophae/vue-treeselect/issues/new). 146 | 147 | ### Contributing 148 | 149 | 1. Fork & clone the repo 150 | 2. Install dependencies by `yarn` or `npm install` 151 | 3. Check out a new branch 152 | 4. `npm run dev` & hack 153 | 5. Make sure `npm test` passes 154 | 6. Push your changes & file a pull request 155 | 156 | ### Credits 157 | 158 | This project is inspired by [vue-multiselect](https://github.com/monterail/vue-multiselect), [react-select](https://github.com/JedWatson/react-select) and [Ant Design](https://github.com/ant-design/ant-design/). Special thanks go to their respective authors! 159 | 160 | Some icons used in this project: 161 | 162 | - "link" icon made by [Smashicons](https://www.flaticon.com/authors/smashicons) is licensed under [CC 3.0 BY](https://creativecommons.org/licenses/by/3.0/) 163 | - "spinner" icon from [SpinKit](https://github.com/tobiasahlin/SpinKit) is licensed under the [MIT License](https://github.com/tobiasahlin/SpinKit/blob/master/LICENSE) 164 | - "caret" icon made by [Dave Gandy](https://www.flaticon.com/authors/dave-gandy) is licensed under [CC 3.0 BY](https://creativecommons.org/licenses/by/3.0/) 165 | - "delete" icon made by [Freepik](https://www.flaticon.com/authors/freepik) is licensed under [CC 3.0 BY](https://creativecommons.org/licenses/by/3.0/) 166 | - "checkmark symbol" & "minus symbol" icons made by [Catalin Fertu](https://www.flaticon.com/authors/catalin-fertu) are licensed under [CC 3.0 BY](https://creativecommons.org/licenses/by/3.0/) 167 | 168 | ### License 169 | 170 | Copyright (c) 2017-present [Riophae Lee](https://github.com/riophae). 171 | 172 | Released under the [MIT License](https://github.com/riophae/vue-treeselect/blob/master/LICENSE). 173 | -------------------------------------------------------------------------------- /docs/partials/guides.pug: -------------------------------------------------------------------------------- 1 | +section('Guides') 2 | 3 | +subsection('Basic Features') 4 | :markdown-it 5 | This example demonstrates the most commonly-used features of vue-treeselect. Try the fuzzy matching functionality by typing a few letters. 6 | +demo('BasicFeatures') 7 | :markdown-it 8 | The first thing you need to learn is how to define options. There are two types of options: **a. folder options** that are foldable & *may* have children options, and **b. normal options** that aren't & don't. Here, I'd like to borrow the basic concepts from Computer Science and call the former as *branch nodes* & the latter as *leaf nodes*. These two kinds of nodes together compose the tree. 9 | 10 | Defining leaf nodes is quite simple: 11 | 12 | ```js 13 | { 14 | id: '', // used to identify the option within the tree so its value must be unique across all options 15 | label: '