├── .gitattributes ├── .travis.yml ├── .npmignore ├── .gitignore ├── preview.png ├── src ├── utils.js ├── List.jsx ├── ListItem.jsx ├── Filter.jsx └── index.jsx ├── .eslintrc ├── .mversionrc ├── .editorconfig ├── .babelrc ├── test ├── helpers │ └── setup-test-env.js └── test.js ├── example ├── index.html ├── style.css └── app.jsx ├── style.css ├── webpack.config.js ├── LICENSE ├── package.json ├── README.md └── CHANGELOG.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - v10 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | example/bundle.js 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | example/bundle.js 4 | /lib 5 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VovanR/react-multiselect-two-sides/HEAD/preview.png -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export function filterBy(option, filter, labelKey) { 2 | return option[labelKey].toLowerCase().indexOf(filter.toLowerCase()) > -1; 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "xo", 4 | "xo-react" 5 | ], 6 | "settings": { 7 | "react": { 8 | "version": "16" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.mversionrc: -------------------------------------------------------------------------------- 1 | { 2 | "commitMessage": "chore(ver): v%s", 3 | "scripts": { 4 | "postcommit": "git push --follow-tags", 5 | "precommit": "conventional-changelog -p angular -i CHANGELOG.md --same-file && git add CHANGELOG.md" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [{*.json,*.yml}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react", 4 | "@babel/preset-env" 5 | ], 6 | "env": { 7 | "production": { 8 | "plugins": [ 9 | [ 10 | "transform-react-remove-prop-types", 11 | { 12 | "removeImport": true 13 | } 14 | ] 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/helpers/setup-test-env.js: -------------------------------------------------------------------------------- 1 | import 'raf/polyfill'; 2 | import {JSDOM} from 'jsdom'; 3 | 4 | import Enzyme from 'enzyme'; 5 | import Adapter from 'enzyme-adapter-react-16'; 6 | Enzyme.configure({adapter: new Adapter()}); 7 | 8 | const jsdom = new JSDOM(''); 9 | const {window} = jsdom; 10 | global.window = window; 11 | global.document = window.document; 12 | global.navigator = { 13 | userAgent: 'node.js' 14 | }; 15 | copyProps(window, global); 16 | 17 | function copyProps(src, target) { 18 | const props = Object.getOwnPropertyNames(src) 19 | .filter(prop => typeof target[prop] === 'undefined') 20 | .map(prop => Object.getOwnPropertyDescriptor(src, prop)); 21 | Object.defineProperties(target, props); 22 | } 23 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | react-multiselect-two-sides 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | .msts { 2 | user-select: none; 3 | cursor: default; 4 | } 5 | 6 | .msts_disabled { 7 | } 8 | 9 | .msts__heading { 10 | display: flex; 11 | } 12 | 13 | .msts__subheading { 14 | display: flex; 15 | } 16 | 17 | .msts__body { 18 | display: flex; 19 | } 20 | 21 | .msts__footer { 22 | display: flex; 23 | } 24 | 25 | .msts__side { 26 | width: 50%; 27 | } 28 | 29 | .msts__side_available { 30 | } 31 | 32 | .msts__side_selected { 33 | } 34 | 35 | .msts__side_controls { 36 | width: auto; 37 | display: flex; 38 | flex-direction: column; 39 | justify-content: center; 40 | } 41 | 42 | .msts__list { 43 | padding: 0; 44 | margin: 0; 45 | } 46 | 47 | .msts__list-item { 48 | list-style-type: none; 49 | cursor: pointer; 50 | } 51 | 52 | .msts__list-item_disabled { 53 | cursor: default; 54 | } 55 | 56 | .msts__list-item_highlighted { 57 | } 58 | 59 | .msts__control { 60 | } 61 | 62 | .msts__control_select-all { 63 | } 64 | 65 | .msts__control_deselect-all { 66 | } 67 | 68 | .msts__filter-input { 69 | } 70 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const precss = require('precss'); 3 | const autoprefixer = require('autoprefixer'); 4 | 5 | module.exports = { 6 | entry: [ 7 | './example/app.jsx' 8 | ], 9 | output: { 10 | path: path.resolve(__dirname, 'example'), 11 | filename: 'bundle.js' 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.jsx$/, 17 | use: 'babel-loader', 18 | exclude: /node_modules/ 19 | }, 20 | { 21 | test: /\.css$/, 22 | use: [ 23 | 'style-loader', 24 | { 25 | loader: 'css-loader', 26 | options: { 27 | importLoaders: 1 28 | } 29 | }, 30 | { 31 | loader: 'postcss-loader', 32 | options: { 33 | plugins: function () { 34 | return [ 35 | precss, 36 | autoprefixer 37 | ]; 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | ] 44 | }, 45 | externals: { 46 | react: 'React', 47 | 'react-dom': 'ReactDOM', 48 | 'prop-types': 'PropTypes' 49 | }, 50 | devtool: 'eval', 51 | resolve: { 52 | modules: [ 53 | path.resolve(__dirname, 'node_modules') 54 | ], 55 | extensions: ['.js', '.jsx'] 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Vladimir Rodkin (https://github.com/VovanR) 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/List.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, {Component} from 'react'; 3 | import ListItem from './ListItem'; 4 | 5 | const propTypes = { 6 | disabled: PropTypes.bool.isRequired, 7 | labelKey: PropTypes.string.isRequired, 8 | onClick: PropTypes.func.isRequired, 9 | options: PropTypes.array.isRequired, 10 | valueKey: PropTypes.string.isRequired 11 | }; 12 | 13 | export default class List extends Component { 14 | constructor(props) { 15 | super(props); 16 | 17 | this.handleClick = this.handleClick.bind(this); 18 | } 19 | 20 | handleClick(value) { 21 | if (this.props.disabled) { 22 | return; 23 | } 24 | 25 | this.props.onClick(value); 26 | } 27 | 28 | render() { 29 | const { 30 | labelKey, 31 | options, 32 | valueKey 33 | } = this.props; 34 | 35 | return ( 36 | 48 | ); 49 | } 50 | } 51 | List.propTypes = propTypes; 52 | -------------------------------------------------------------------------------- /src/ListItem.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, {Component} from 'react'; 3 | import classNames from 'classnames'; 4 | 5 | const propTypes = { 6 | disabled: PropTypes.bool, 7 | highlighted: PropTypes.bool, 8 | label: PropTypes.string, 9 | onClick: PropTypes.func.isRequired, 10 | value: PropTypes.oneOfType([ 11 | PropTypes.number, 12 | PropTypes.string 13 | ]).isRequired 14 | }; 15 | const defaultProps = { 16 | disabled: false, 17 | highlighted: false, 18 | label: '' 19 | }; 20 | 21 | export default class ListItem extends Component { 22 | constructor(props) { 23 | super(props); 24 | 25 | this.handleClick = this.handleClick.bind(this); 26 | } 27 | 28 | handleClick() { 29 | if (this.props.disabled) { 30 | return; 31 | } 32 | 33 | const { 34 | onClick, 35 | value 36 | } = this.props; 37 | onClick(value); 38 | } 39 | 40 | render() { 41 | const { 42 | disabled, 43 | highlighted, 44 | label 45 | } = this.props; 46 | const className = 'msts__list-item'; 47 | 48 | return ( 49 |
  • 53 | {label} 54 |
  • 55 | ); 56 | } 57 | } 58 | ListItem.propTypes = propTypes; 59 | ListItem.defaultProps = defaultProps; 60 | -------------------------------------------------------------------------------- /src/Filter.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, {Component} from 'react'; 3 | 4 | const propTypes = { 5 | clearFilterText: PropTypes.string.isRequired, 6 | clearable: PropTypes.bool.isRequired, 7 | disabled: PropTypes.bool.isRequired, 8 | onChange: PropTypes.func.isRequired, 9 | placeholder: PropTypes.string.isRequired, 10 | value: PropTypes.string.isRequired 11 | }; 12 | 13 | export default class Filter extends Component { 14 | constructor(props) { 15 | super(props); 16 | 17 | this.handleChange = this.handleChange.bind(this); 18 | this.handleClickClear = this.handleClickClear.bind(this); 19 | } 20 | 21 | handleChange(e) { 22 | this.props.onChange(e.target.value); 23 | } 24 | 25 | handleClickClear() { 26 | this.props.onChange(''); 27 | } 28 | 29 | render() { 30 | const { 31 | clearFilterText, 32 | clearable, 33 | disabled, 34 | placeholder, 35 | value 36 | } = this.props; 37 | 38 | return ( 39 |
    40 | 48 | 49 | {clearable && value && !disabled ? ( 50 | 55 | ) : null} 56 |
    57 | ); 58 | } 59 | } 60 | Filter.propTypes = propTypes; 61 | -------------------------------------------------------------------------------- /example/style.css: -------------------------------------------------------------------------------- 1 | /* Example theme */ 2 | .msts_theme_example { 3 | border: 1px solid silver; 4 | 5 | .msts__heading { 6 | .msts__side { 7 | padding: 5px; 8 | text-align: center; 9 | color: #fff; 10 | font-weight: bold; 11 | } 12 | 13 | .msts__side_available { 14 | background-color: #9b59b6; 15 | } 16 | 17 | .msts__side_selected { 18 | background-color: #2ecc71; 19 | } 20 | } 21 | 22 | .msts__subheading { 23 | .msts__side_filter { 24 | padding: 5px; 25 | } 26 | } 27 | 28 | .msts__footer { 29 | .msts__side { 30 | padding: 5px 15px; 31 | background-color: #ecf0f1; 32 | font-size: 0.75em; 33 | color: #7f8c8d; 34 | text-align: right; 35 | } 36 | } 37 | 38 | .msts__list { 39 | height: 140px; 40 | overflow-y: auto; 41 | overflow-x: hidden; 42 | } 43 | 44 | .msts__list-item { 45 | padding: 5px 10px; 46 | transition: background-color ease-in 0.1s, color ease-in 0.1s; 47 | 48 | &:hover { 49 | background-color: #2980b9; 50 | color: #fff; 51 | } 52 | 53 | &_disabled { 54 | background-color: #ecf0f1; 55 | color: #7f8c8d; 56 | 57 | &:hover { 58 | background-color: #ecf0f1; 59 | color: #7f8c8d; 60 | } 61 | } 62 | 63 | &_highlighted { 64 | background-color: rgba(41, 128, 185, 0.25); 65 | } 66 | } 67 | 68 | .msts__control { 69 | border: none; 70 | background: none; 71 | cursor: pointer; 72 | padding: 10px 3px; 73 | color: #bdc3c7; 74 | transition: color ease 0.15s; 75 | 76 | &:hover { 77 | color: #95a5a6; 78 | } 79 | 80 | &[disabled] { 81 | color: #ecf0f1; 82 | } 83 | } 84 | 85 | .msts__control_select-all { 86 | &:after { 87 | content: '❯'; 88 | } 89 | } 90 | 91 | .msts__control_deselect-all { 92 | &:after { 93 | content: '❮'; 94 | } 95 | } 96 | 97 | .msts__filter { 98 | position: relative; 99 | } 100 | 101 | .msts__filter-input { 102 | width: 100%; 103 | box-sizing: border-box; 104 | padding: 5px; 105 | border: 1px solid silver; 106 | } 107 | 108 | .msts__filter-clear { 109 | cursor: pointer; 110 | position: absolute; 111 | top: 0; 112 | right: 0; 113 | height: 100%; 114 | padding: 0 10px; 115 | font-size: 1.25em; 116 | color: silver; 117 | transition: color ease 0.15s; 118 | 119 | &:after { 120 | content: '×'; 121 | vertical-align: middle; 122 | } 123 | 124 | &:hover { 125 | color: #c0392b; 126 | } 127 | } 128 | } 129 | 130 | .msts_theme_example.msts_disabled { 131 | background-color: #ecf0f1; 132 | } 133 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-multiselect-two-sides", 3 | "version": "0.16.0", 4 | "description": "React multiselect two sides component", 5 | "license": "MIT", 6 | "repository": "VovanR/react-multiselect-two-sides", 7 | "author": { 8 | "name": "Vladimir Rodkin", 9 | "email": "mail@vovanr.com", 10 | "url": "https://github.com/VovanR" 11 | }, 12 | "scripts": { 13 | "build": "cross-env NODE_ENV=production babel src --out-dir lib", 14 | "build:example": "cross-env NODE_ENV=production webpack -p", 15 | "start": "webpack --watch", 16 | "test": "npm run lint && ava --verbose", 17 | "lint": "eslint src/* test/*.js test/**/*.js webpack.config.js example/app.jsx", 18 | "tdd": "ava --watch", 19 | "release-patch": "mversion patch", 20 | "release-minor": "mversion minor", 21 | "release-major": "mversion major", 22 | "deploy": "npm run build:example && gh-pages -d example", 23 | "prepublishOnly": "del-cli lib && npm run build" 24 | }, 25 | "files": [ 26 | "lib", 27 | "style.css" 28 | ], 29 | "main": "lib/index.js", 30 | "style": "style.css", 31 | "keywords": [ 32 | "react", 33 | "component", 34 | "react-component", 35 | "select", 36 | "multiselect", 37 | "input" 38 | ], 39 | "dependencies": { 40 | "classnames": "^2.2.6", 41 | "prop-types": "^15.7.2" 42 | }, 43 | "devDependencies": { 44 | "@babel/cli": "^7.4.4", 45 | "@babel/core": "^7.4.5", 46 | "@babel/preset-env": "^7.4.5", 47 | "@babel/preset-react": "^7.0.0", 48 | "@babel/register": "^7.4.4", 49 | "autoprefixer": "^9.5.1", 50 | "ava": "1.4.1", 51 | "babel-loader": "^8.0.6", 52 | "babel-plugin-transform-react-remove-prop-types": "^0.4.24", 53 | "conventional-changelog-cli": "^2.0.21", 54 | "cross-env": "^5.2.0", 55 | "css-loader": "^2.1.1", 56 | "cz-conventional-changelog": "^2.1.0", 57 | "del-cli": "^1.1.0", 58 | "enzyme": "^3.9.0", 59 | "enzyme-adapter-react-16": "^1.13.1", 60 | "eslint": "^5.16.0", 61 | "eslint-config-xo": "^0.26.0", 62 | "eslint-config-xo-react": "^0.19.0", 63 | "eslint-plugin-react": "^7.13.0", 64 | "eslint-plugin-react-hooks": "^1.6.0", 65 | "eslint-plugin-xo": "^1.0.0", 66 | "gh-pages": "^2.0.1", 67 | "husky": "^2.3.0", 68 | "jsdom": "^15.1.0", 69 | "mversion": "^1.13.0", 70 | "postcss-loader": "^3.0.0", 71 | "precss": "^4.0.0", 72 | "raf": "^3.4.1", 73 | "react": "^16.8.6", 74 | "react-dom": "^16.8.6", 75 | "style-loader": "^0.23.1", 76 | "webpack": "^4.32.2", 77 | "webpack-cli": "^3.3.2" 78 | }, 79 | "peerDependencies": { 80 | "classnames": "*", 81 | "prop-types": "*", 82 | "react": "^15.0.0 || ^16.0.0", 83 | "react-dom": "^15.0.0 || ^16.0.0" 84 | }, 85 | "ava": { 86 | "require": [ 87 | "@babel/register", 88 | "./test/helpers/setup-test-env.js" 89 | ] 90 | }, 91 | "config": { 92 | "commitizen": { 93 | "path": "./node_modules/cz-conventional-changelog" 94 | } 95 | }, 96 | "husky": { 97 | "pre-commit": "npm test" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-multiselect-two-sides 2 | 3 | [![Commitizen friendly][commitizen-image]][commitizen-url] 4 | [![XO code style][codestyle-image]][codestyle-url] 5 | 6 | [![NPM version][npm-image]][npm-url] 7 | [![Build Status][travis-image]][travis-url] 8 | 9 | > React multiselect two sides component 10 | 11 | Demo: [vovanr.github.io/react-multiselect-two-sides][demo] 12 | 13 | ![](preview.png) 14 | 15 | ## Install 16 | 17 | ```shell 18 | npm install --save react-multiselect-two-sides 19 | ``` 20 | 21 | ## Usage 22 | See: [example](example/app.jsx) 23 | 24 | ```js 25 | class App extends React.Component { 26 | constructor(props) { 27 | super(props); 28 | 29 | this.state = { 30 | options: [ 31 | {name: 'Foo', value: 0}, 32 | {name: 'Bar', value: 1}, 33 | {name: 'Baz', value: 2, disabled: true}, 34 | {name: 'Qux', value: 3}, 35 | {name: 'Quux', value: 4}, 36 | {name: 'Corge', value: 5}, 37 | {name: 'Grault', value: 6}, 38 | {name: 'Garply', value: 7}, 39 | {name: 'Waldo', value: 8}, 40 | {name: 'Fred', value: 9}, 41 | {name: 'Plugh', value: 10}, 42 | {name: 'Xyzzy', value: 11}, 43 | {name: 'Thud', value: 12} 44 | ], 45 | value: [0, 3, 9] 46 | }; 47 | }, 48 | 49 | handleChange = (value) => { 50 | this.setState({value}); 51 | } 52 | 53 | render() { 54 | const {options, value} = this.state; 55 | const selectedCount = value.length; 56 | const availableCount = options.length - selectedCount; 57 | 58 | return ( 59 | 71 | ); 72 | } 73 | } 74 | 75 | ReactDOM.render(, document.getElementById('app')); 76 | ``` 77 | 78 | ## Api 79 | 80 | ```js 81 | MultiselectTwoSides.propTypes = { 82 | availableFooter: PropTypes.node, 83 | availableHeader: PropTypes.node, 84 | className: PropTypes.string, 85 | clearFilterText: PropTypes.string, 86 | clearable: PropTypes.bool, 87 | deselectAllText: PropTypes.string, 88 | disabled: PropTypes.bool, 89 | filterBy: PropTypes.func, 90 | filterComponent: PropTypes.func, 91 | highlight: PropTypes.array, 92 | labelKey: PropTypes.string, 93 | limit: PropTypes.number, 94 | onChange: PropTypes.func, 95 | options: PropTypes.array, 96 | placeholder: PropTypes.string, 97 | searchable: PropTypes.bool, 98 | selectAllText: PropTypes.string, 99 | selectedFooter: PropTypes.node, 100 | selectedHeader: PropTypes.node, 101 | showControls: PropTypes.bool, 102 | value: PropTypes.array, 103 | valueKey: PropTypes.string 104 | }; 105 | 106 | MultiselectTwoSides.defaultProps = { 107 | availableFooter: null, 108 | availableHeader: null, 109 | className: null, 110 | clearFilterText: 'Clear', 111 | clearable: true, 112 | deselectAllText: 'Deselect all', 113 | disabled: false, 114 | // Case-insensitive filter 115 | filterBy: (option, filter, labelKey) => option[labelKey].toLowerCase().indexOf(filter.toLowerCase()) > -1, 116 | filterComponent: null, 117 | highlight: [], 118 | labelKey: 'label', 119 | limit: undefined, 120 | onChange: () => {}, 121 | options: [], 122 | placeholder: '', 123 | searchable: false, 124 | selectAllText: 'Select all', 125 | selectedFooter: null, 126 | selectedHeader: null, 127 | showControls: false, 128 | value: [], 129 | valueKey: 'value' 130 | }; 131 | ``` 132 | 133 | ## License 134 | MIT © [Vladimir Rodkin](https://github.com/VovanR) 135 | 136 | [demo]: https://vovanr.github.io/react-multiselect-two-sides 137 | 138 | [commitizen-url]: https://commitizen.github.io/cz-cli/ 139 | [commitizen-image]: https://img.shields.io/badge/commitizen-friendly-brightgreen.svg?style=flat-square 140 | 141 | [codestyle-url]: https://github.com/sindresorhus/xo 142 | [codestyle-image]: https://img.shields.io/badge/code_style-XO-5ed9c7.svg?style=flat-square 143 | 144 | [npm-url]: https://npmjs.org/package/react-multiselect-two-sides 145 | [npm-image]: https://img.shields.io/npm/v/react-multiselect-two-sides.svg?style=flat-square 146 | 147 | [travis-url]: https://travis-ci.org/VovanR/react-multiselect-two-sides 148 | [travis-image]: https://img.shields.io/travis/VovanR/react-multiselect-two-sides.svg?style=flat-square 149 | -------------------------------------------------------------------------------- /example/app.jsx: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | /* eslint react/forbid-component-props: 0 */ 3 | 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | import PropTypes from 'prop-types'; 7 | import MultiselectTwoSides from '../src'; 8 | 9 | require('../style.css'); 10 | require('./style.css'); 11 | 12 | class Checkbox extends React.Component { 13 | constructor(props) { 14 | super(props); 15 | 16 | this.handleChange = this.handleChange.bind(this); 17 | } 18 | 19 | handleChange(e) { 20 | this.props.onChange(e); 21 | } 22 | 23 | render() { 24 | const { 25 | disabled, 26 | label, 27 | name, 28 | value 29 | } = this.props; 30 | 31 | return ( 32 | 43 | ); 44 | } 45 | } 46 | Checkbox.propTypes = { 47 | disabled: PropTypes.bool, 48 | label: PropTypes.string.isRequired, 49 | name: PropTypes.string.isRequired, 50 | onChange: PropTypes.func.isRequired, 51 | value: PropTypes.bool.isRequired 52 | }; 53 | Checkbox.defaultProps = { 54 | disabled: false 55 | }; 56 | 57 | class App extends React.Component { 58 | constructor(props) { 59 | super(props); 60 | 61 | this.state = { 62 | options: [ 63 | {label: 'Foo', value: 0}, 64 | {label: 'Bar', value: 1}, 65 | {label: 'Baz', value: 2, disabled: true}, 66 | {label: 'Qux', value: 3}, 67 | {label: 'Quux', value: 4}, 68 | {label: 'Corge', value: 5}, 69 | {label: 'Grault', value: 6}, 70 | {label: 'Garply', value: 7}, 71 | {label: 'Waldo', value: 8}, 72 | {label: 'Fred', value: 9}, 73 | {label: 'Plugh', value: 10}, 74 | {label: 'Xyzzy', value: 11}, 75 | {label: 'Thud', value: 12} 76 | ], 77 | value: [0, 3, 9], 78 | highlight: [5, 8, 9], 79 | settings: [ 80 | { 81 | label: 'Show controls', 82 | name: 'showControls', 83 | value: true 84 | }, 85 | { 86 | label: 'Searchable', 87 | name: 'searchable', 88 | value: true 89 | }, 90 | { 91 | label: 'Clearable', 92 | name: 'clearable', 93 | value: true 94 | }, 95 | { 96 | label: 'Disabled', 97 | name: 'disabled', 98 | value: false 99 | }, 100 | { 101 | label: 'Limit', 102 | name: 'limit', 103 | value: 5 104 | } 105 | ] 106 | }; 107 | 108 | this.handleChange = this.handleChange.bind(this); 109 | this.handleChangeSetting = this.handleChangeSetting.bind(this); 110 | } 111 | 112 | handleChange(value) { 113 | this.setState({value}); 114 | } 115 | 116 | handleChangeSetting(e) { 117 | const { 118 | name, 119 | value 120 | } = e.target; 121 | 122 | this.setState(state => { 123 | const setting = this.getSettingByName(state, name); 124 | const newValue = typeof setting.value === 'boolean' ? !setting.value : parseInt(value, 10); 125 | setting.value = newValue; 126 | 127 | if (name === 'searchable') { 128 | this.getSettingByName(state, 'clearable').disabled = !newValue; 129 | } 130 | 131 | return state; 132 | }); 133 | } 134 | 135 | getSettingByName(state, name) { 136 | let result; 137 | 138 | for (const setting of state.settings) { 139 | if (setting.name === name) { 140 | result = setting; 141 | continue; 142 | } 143 | } 144 | 145 | return result; 146 | } 147 | 148 | render() { 149 | const { 150 | highlight, 151 | options, 152 | settings, 153 | value 154 | } = this.state; 155 | const selectedCount = value.length; 156 | const availableCount = options.length - selectedCount; 157 | const s = settings.reduce((a, b) => { 158 | a[b.name] = b.value; 159 | return a; 160 | }, {}); 161 | 162 | return ( 163 |
    164 |

    165 | {settings.map(setting => { 166 | if (typeof setting.value === 'boolean') { 167 | return ( 168 | 173 | ); 174 | } 175 | 176 | if (typeof setting.value === 'number') { 177 | return ( 178 | 190 | ); 191 | } 192 | 193 | return null; 194 | })} 195 |

    196 | 197 | 210 |
    211 | ); 212 | } 213 | } 214 | 215 | ReactDOM.render(, document.getElementById('app')); 216 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [0.16.0](https://github.com/VovanR/react-multiselect-two-sides/compare/v0.15.0...v0.16.0) (2019-05-23) 2 | 3 | 4 | ### Features 5 | 6 | * **lib:** Deselect All deselect only filtered values ([ad2a772](https://github.com/VovanR/react-multiselect-two-sides/commit/ad2a772)), closes [#31](https://github.com/VovanR/react-multiselect-two-sides/issues/31) 7 | * **lib:** Select All adds only filtered values ([4da1587](https://github.com/VovanR/react-multiselect-two-sides/commit/4da1587)), closes [#31](https://github.com/VovanR/react-multiselect-two-sides/issues/31) 8 | 9 | 10 | 11 | 12 | # [0.15.0](https://github.com/VovanR/react-multiselect-two-sides/compare/v0.14.0...v0.15.0) (2018-09-17) 13 | 14 | 15 | ### Features 16 | 17 | * **lib:** Add `filterBy` property ([34b1b2d](https://github.com/VovanR/react-multiselect-two-sides/commit/34b1b2d)), closes [#22](https://github.com/VovanR/react-multiselect-two-sides/issues/22) 18 | 19 | 20 | 21 | 22 | # [0.14.0](https://github.com/VovanR/react-multiselect-two-sides/compare/v0.13.2...v0.14.0) (2018-07-19) 23 | 24 | 25 | 26 | 27 | ## [0.13.2](https://github.com/VovanR/react-multiselect-two-sides/compare/v0.13.1...v0.13.2) (2018-07-17) 28 | 29 | 30 | 31 | 32 | ## [0.13.1](https://github.com/VovanR/react-multiselect-two-sides/compare/v0.13.0...v0.13.1) (2018-01-30) 33 | 34 | 35 | 36 | 37 | # [0.13.0](https://github.com/VovanR/react-multiselect-two-sides/compare/v0.12.3...v0.13.0) (2018-01-30) 38 | 39 | 40 | 41 | 42 | ## [0.12.3](https://github.com/VovanR/react-multiselect-two-sides/compare/v0.12.2...v0.12.3) (2018-01-30) 43 | 44 | 45 | 46 | 47 | ## [0.12.2](https://github.com/VovanR/react-multiselect-two-sides/compare/v0.12.1...v0.12.2) (2018-01-30) 48 | 49 | 50 | 51 | 52 | ## [0.12.1](https://github.com/VovanR/react-multiselect-two-sides/compare/v0.12.0...v0.12.1) (2018-01-30) 53 | 54 | 55 | ### Bug Fixes 56 | 57 | * **lib:** Remove spread props objects ([85b2aa0](https://github.com/VovanR/react-multiselect-two-sides/commit/85b2aa0)), closes [#19](https://github.com/VovanR/react-multiselect-two-sides/issues/19) 58 | * **lib:** Split main component file to sub-component files ([42e2618](https://github.com/VovanR/react-multiselect-two-sides/commit/42e2618)), closes [#19](https://github.com/VovanR/react-multiselect-two-sides/issues/19) 59 | 60 | 61 | 62 | 63 | # [0.12.0](https://github.com/VovanR/react-multiselect-two-sides/compare/v0.11.0...v0.12.0) (2018-01-09) 64 | 65 | 66 | ### Features 67 | 68 | * **lib:** Migrate from React.createClass to ES2015 class extends React.Component; Add React 16 supp ([3af730d](https://github.com/VovanR/react-multiselect-two-sides/commit/3af730d)) 69 | 70 | 71 | 72 | 73 | # [0.11.0](https://github.com/vovanr/react-multiselect-two-sides/compare/v0.10.0...v0.11.0) (2016-11-02) 74 | 75 | 76 | ### Features 77 | 78 | * **lib:** Add `highlight` property ([2ff7308](https://github.com/vovanr/react-multiselect-two-sides/commit/2ff7308)), closes [#10](https://github.com/vovanr/react-multiselect-two-sides/issues/10) 79 | 80 | 81 | 82 | 83 | # [0.10.0](https://github.com/vovanr/react-multiselect-two-sides/compare/v0.9.0...v0.10.0) (2016-10-21) 84 | 85 | 86 | 87 | 88 | # [0.9.0](https://github.com/vovanr/react-multiselect-two-sides/compare/v0.8.1...v0.9.0) (2016-10-18) 89 | 90 | 91 | ### Features 92 | 93 | * **lib:** Add `limit` property ([25ef541](https://github.com/vovanr/react-multiselect-two-sides/commit/25ef541)), closes [#9](https://github.com/vovanr/react-multiselect-two-sides/issues/9) 94 | 95 | 96 | 97 | 98 | ## [0.8.1](https://github.com/vovanr/react-multiselect-two-sides/compare/v0.8.0...v0.8.1) (2016-05-30) 99 | 100 | 101 | 102 | 103 | # [0.8.0](https://github.com/vovanr/react-multiselect-two-sides/compare/v0.7.0...v0.8.0) (2016-05-27) 104 | 105 | 106 | ### Features 107 | 108 | * **lib:** Add `labelKey` and `valueKey` properties ([be99ac7](https://github.com/vovanr/react-multiselect-two-sides/commit/be99ac7)), closes [#3](https://github.com/vovanr/react-multiselect-two-sides/issues/3) 109 | * **lib:** Add `labelKey` and `valueKey` properties ([d842bfa](https://github.com/vovanr/react-multiselect-two-sides/commit/d842bfa)) 110 | * **lib:** Add text properties ([0522a05](https://github.com/vovanr/react-multiselect-two-sides/commit/0522a05)) 111 | 112 | 113 | 114 | 115 | # [0.7.0](https://github.com/vovanr/react-multiselect-two-sides/compare/v0.6.0...v0.7.0) (2016-05-27) 116 | 117 | 118 | ### Features 119 | 120 | * **lib:** Add `disabled` property for list item disabling ([a803939](https://github.com/vovanr/react-multiselect-two-sides/commit/a803939)), closes [#7](https://github.com/vovanr/react-multiselect-two-sides/issues/7) 121 | 122 | 123 | 124 | 125 | # [0.6.0](https://github.com/vovanr/react-multiselect-two-sides/compare/v0.5.1...v0.6.0) (2016-05-26) 126 | 127 | 128 | ### Bug Fixes 129 | 130 | * **lib:** Change `clearable` property enabled by default ([5009766](https://github.com/vovanr/react-multiselect-two-sides/commit/5009766)) 131 | 132 | 133 | ### Features 134 | 135 | * **lib:** Add `disabled` property for component disabling ([f2ad5e7](https://github.com/vovanr/react-multiselect-two-sides/commit/f2ad5e7)), closes [#2](https://github.com/vovanr/react-multiselect-two-sides/issues/2) 136 | 137 | 138 | 139 | 140 | ## [0.5.1](https://github.com/vovanr/react-multiselect-two-sides/compare/v0.5.0...v0.5.1) (2016-05-25) 141 | 142 | 143 | ### Bug Fixes 144 | 145 | * **lib:** Hide clear filter button if filter is empty ([c4b1b42](https://github.com/vovanr/react-multiselect-two-sides/commit/c4b1b42)) 146 | 147 | 148 | 149 | 150 | # [0.5.0](https://github.com/vovanr/react-multiselect-two-sides/compare/v0.4.0...v0.5.0) (2016-05-24) 151 | 152 | 153 | ### Features 154 | 155 | * **lib:** Add clear filter button ([434c898](https://github.com/vovanr/react-multiselect-two-sides/commit/434c898)), closes [#6](https://github.com/vovanr/react-multiselect-two-sides/issues/6) 156 | 157 | 158 | 159 | 160 | # [0.4.0](https://github.com/vovanr/react-multiselect-two-sides/compare/v0.3.0...v0.4.0) (2016-05-24) 161 | 162 | 163 | ### Features 164 | 165 | * **lib:** Add filter lists ([f9b8866](https://github.com/vovanr/react-multiselect-two-sides/commit/f9b8866)), closes [#5](https://github.com/vovanr/react-multiselect-two-sides/issues/5) 166 | 167 | 168 | 169 | 170 | # [0.3.0](https://github.com/vovanr/react-multiselect-two-sides/compare/v0.2.0...v0.3.0) (2016-05-23) 171 | 172 | 173 | ### Features 174 | 175 | * **lib:** Add controls select/deselect all ([a5b30ec](https://github.com/vovanr/react-multiselect-two-sides/commit/a5b30ec)), closes [#4](https://github.com/vovanr/react-multiselect-two-sides/issues/4) 176 | 177 | 178 | 179 | 180 | # [0.2.0](https://github.com/vovanr/react-multiselect-two-sides/compare/v0.1.0...v0.2.0) (2016-05-21) 181 | 182 | 183 | ### Features 184 | 185 | * **lib:** Add headers and footers props ([d3bc057](https://github.com/vovanr/react-multiselect-two-sides/commit/d3bc057)), closes [#1](https://github.com/vovanr/react-multiselect-two-sides/issues/1) 186 | 187 | 188 | 189 | 190 | # 0.1.0 (2016-05-19) 191 | 192 | 193 | ### Features 194 | 195 | * **package:** Init ([ce5d92e](https://github.com/vovanr/react-multiselect-two-sides/commit/ce5d92e)) 196 | 197 | 198 | 199 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import classNames from 'classnames'; 3 | import PropTypes from 'prop-types'; 4 | import {filterBy} from './utils'; 5 | import Filter from './Filter'; 6 | import List from './List'; 7 | 8 | const propTypes = { 9 | availableFooter: PropTypes.node, 10 | availableHeader: PropTypes.node, 11 | className: PropTypes.string, 12 | clearFilterText: PropTypes.string, 13 | clearable: PropTypes.bool, 14 | deselectAllText: PropTypes.string, 15 | disabled: PropTypes.bool, 16 | filterBy: PropTypes.func, 17 | filterComponent: PropTypes.func, 18 | highlight: PropTypes.array, 19 | labelKey: PropTypes.string, 20 | limit: PropTypes.number, 21 | onChange: PropTypes.func, 22 | options: PropTypes.array, 23 | placeholder: PropTypes.string, 24 | searchable: PropTypes.bool, 25 | selectAllText: PropTypes.string, 26 | selectedFooter: PropTypes.node, 27 | selectedHeader: PropTypes.node, 28 | showControls: PropTypes.bool, 29 | value: PropTypes.array, 30 | valueKey: PropTypes.string 31 | }; 32 | const defaultProps = { 33 | availableFooter: null, 34 | availableHeader: null, 35 | className: null, 36 | clearFilterText: 'Clear', 37 | clearable: true, 38 | deselectAllText: 'Deselect all', 39 | disabled: false, 40 | filterBy: filterBy, 41 | filterComponent: null, 42 | highlight: [], 43 | labelKey: 'label', 44 | limit: undefined, 45 | onChange: () => {}, 46 | options: [], 47 | placeholder: '', 48 | searchable: false, 49 | selectAllText: 'Select all', 50 | selectedFooter: null, 51 | selectedHeader: null, 52 | showControls: false, 53 | value: [], 54 | valueKey: 'value' 55 | }; 56 | 57 | export default class MultiselectTwoSides extends Component { 58 | constructor(props) { 59 | super(props); 60 | 61 | this.state = { 62 | filterAvailable: '', 63 | filterSelected: '' 64 | }; 65 | 66 | this.handleClickAvailable = this.handleClickAvailable.bind(this); 67 | this.handleClickSelected = this.handleClickSelected.bind(this); 68 | this.handleClickSelectAll = this.handleClickSelectAll.bind(this); 69 | this.handleClickDeselectAll = this.handleClickDeselectAll.bind(this); 70 | this.handleChangeFilterAvailable = this.handleChangeFilterAvailable.bind(this); 71 | this.handleChangeFilterSelected = this.handleChangeFilterSelected.bind(this); 72 | } 73 | 74 | handleClickAvailable(value) { 75 | this.props.onChange(this.props.value.concat(value)); 76 | } 77 | 78 | handleClickSelected(value) { 79 | const { 80 | onChange, 81 | value: currentValue 82 | } = this.props; 83 | const newValue = currentValue.slice(); 84 | 85 | newValue.splice(currentValue.indexOf(value), 1); 86 | onChange(newValue); 87 | } 88 | 89 | handleClickSelectAll() { 90 | const { 91 | limit, 92 | onChange, 93 | value, 94 | valueKey 95 | } = this.props; 96 | const previousValue = value.slice(); 97 | 98 | const options = this.filterAvailable(); 99 | 100 | const newValue = options.reduce((acc, option) => { 101 | if (!option.disabled && previousValue.indexOf(option[valueKey]) === -1) { 102 | acc.push(option[valueKey]); 103 | } 104 | 105 | return acc; 106 | }, previousValue); 107 | 108 | let limitedValue = newValue; 109 | if (limit >= 0) { 110 | limitedValue = limitedValue.slice(0, limit); 111 | } 112 | 113 | limitedValue.sort(); 114 | 115 | onChange(limitedValue); 116 | } 117 | 118 | handleClickDeselectAll() { 119 | const { 120 | onChange, 121 | value, 122 | valueKey 123 | } = this.props; 124 | const previousValue = value.slice(); 125 | 126 | const options = this.filterActive(); 127 | 128 | const optionsValueMap = options.reduce((acc, option) => { 129 | acc[option[valueKey]] = true; 130 | 131 | return acc; 132 | }, {}); 133 | 134 | const newValue = previousValue.reduce((acc, value) => { 135 | if (!optionsValueMap[value]) { 136 | acc.push(value); 137 | } 138 | 139 | return acc; 140 | }, []); 141 | 142 | onChange(newValue); 143 | } 144 | 145 | filterAvailable() { 146 | const { 147 | filterBy, 148 | highlight, 149 | labelKey, 150 | limit, 151 | options, 152 | value, 153 | valueKey, 154 | searchable 155 | } = this.props; 156 | 157 | const filtered = options.reduce((acc, option) => { 158 | if (value.indexOf(option[valueKey]) === -1) { 159 | acc.push(option); 160 | } 161 | 162 | return acc; 163 | }, []); 164 | 165 | let limited = filtered; 166 | if (value.length >= limit) { 167 | limited = filtered.map(option => { 168 | return Object.assign({}, option, {disabled: true}); 169 | }); 170 | } 171 | 172 | if (highlight && highlight.length > 0) { 173 | limited = limited.map(option => { 174 | if (highlight.indexOf(option[valueKey]) > -1) { 175 | return Object.assign({}, option, {highlighted: true}); 176 | } 177 | 178 | return option; 179 | }); 180 | } 181 | 182 | if (!searchable) { 183 | return limited; 184 | } 185 | 186 | const { 187 | filterAvailable: filter 188 | } = this.state; 189 | if (filter) { 190 | return limited.filter(option => filterBy(option, filter, labelKey)); 191 | } 192 | 193 | return limited; 194 | } 195 | 196 | filterActive() { 197 | const { 198 | filterBy, 199 | labelKey, 200 | options, 201 | value, 202 | valueKey 203 | } = this.props; 204 | const filtered = options.reduce((acc, option) => { 205 | if (value.indexOf(option[valueKey]) > -1) { 206 | acc.push(option); 207 | } 208 | 209 | return acc; 210 | }, []); 211 | 212 | if (!this.props.searchable) { 213 | return filtered; 214 | } 215 | 216 | const {filterSelected: filter} = this.state; 217 | if (filter) { 218 | return filtered.filter(option => filterBy(option, filter, labelKey)); 219 | } 220 | 221 | return filtered; 222 | } 223 | 224 | handleChangeFilterAvailable(filterAvailable) { 225 | this.setState({filterAvailable}); 226 | } 227 | 228 | handleChangeFilterSelected(filterSelected) { 229 | this.setState({filterSelected}); 230 | } 231 | 232 | renderFilter(value, onChange) { 233 | const { 234 | clearFilterText, 235 | clearable, 236 | disabled, 237 | filterComponent, 238 | placeholder 239 | } = this.props; 240 | 241 | if (!filterComponent) { 242 | return ( 243 | 251 | ); 252 | } 253 | 254 | return React.createElement(filterComponent, { 255 | clearFilterText, 256 | clearable, 257 | disabled, 258 | onChange, 259 | placeholder, 260 | value 261 | }); 262 | } 263 | 264 | render() { 265 | const { 266 | availableFooter, 267 | availableHeader, 268 | className, 269 | deselectAllText, 270 | disabled, 271 | labelKey, 272 | limit, 273 | options, 274 | searchable, 275 | selectAllText, 276 | selectedFooter, 277 | selectedHeader, 278 | showControls, 279 | value, 280 | valueKey 281 | } = this.props; 282 | 283 | const { 284 | filterAvailable, 285 | filterSelected 286 | } = this.state; 287 | 288 | return ( 289 |
    290 | {availableHeader || selectedHeader ? ( 291 |
    292 |
    293 | {availableHeader} 294 |
    295 | 296 |
    297 | {selectedHeader} 298 |
    299 |
    300 | ) : null} 301 | 302 | {searchable ? ( 303 |
    304 |
    305 | {this.renderFilter(filterAvailable, this.handleChangeFilterAvailable)} 306 |
    307 | 308 |
    309 | {this.renderFilter(filterSelected, this.handleChangeFilterSelected)} 310 |
    311 |
    312 | ) : null} 313 | 314 |
    315 |
    316 | 323 |
    324 | 325 | {showControls ? ( 326 |
    327 |
    343 | ) : null} 344 | 345 |
    346 | 353 |
    354 |
    355 | 356 | {availableFooter || selectedFooter ? ( 357 |
    358 |
    359 | {availableFooter} 360 |
    361 | 362 |
    363 | {selectedFooter} 364 |
    365 |
    366 | ) : null} 367 |
    368 | ); 369 | } 370 | } 371 | MultiselectTwoSides.propTypes = propTypes; 372 | MultiselectTwoSides.defaultProps = defaultProps; 373 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import React from 'react'; 3 | import {shallow, mount} from 'enzyme'; 4 | import C from '../src'; 5 | 6 | test('render component block', t => { 7 | const wrapper = shallow(); 8 | t.true(wrapper.hasClass('msts')); 9 | t.is(wrapper.type('div'), 'div'); 10 | }); 11 | 12 | test('allow to add custom class name', t => { 13 | const props = { 14 | className: 'foo bar' 15 | }; 16 | const wrapper = shallow(); 17 | t.true(wrapper.hasClass('msts foo bar')); 18 | }); 19 | 20 | test('render children blocks', t => { 21 | const props = { 22 | }; 23 | const wrapper = shallow(); 24 | t.true(wrapper.find('.msts__side').length === 2); 25 | 26 | const sideAvailable = wrapper.find('.msts__side_available'); 27 | t.is(sideAvailable.type(), 'div'); 28 | t.true(sideAvailable.hasClass('msts__side msts__side_available')); 29 | 30 | const sideSelected = wrapper.find('.msts__side_selected'); 31 | t.is(sideSelected.type(), 'div'); 32 | t.true(sideSelected.hasClass('msts__side msts__side_selected')); 33 | }); 34 | 35 | test('render list items', t => { 36 | const props = { 37 | options: [ 38 | {label: 'Foo', value: 0}, 39 | {label: 'Bar', value: 1} 40 | ], 41 | value: [1] 42 | }; 43 | const wrapper = mount(); 44 | const items = wrapper.find('.msts__list-item'); 45 | t.is(items.get(0).props.children, 'Foo'); 46 | t.is(items.get(1).props.children, 'Bar'); 47 | }); 48 | 49 | test('render controls', t => { 50 | const props = { 51 | showControls: true 52 | }; 53 | const wrapper = shallow(); 54 | const items = wrapper.find('.msts__side_controls'); 55 | t.is(items.type(), 'div'); 56 | t.true(items.hasClass('msts__side msts__side_controls')); 57 | }); 58 | 59 | test('dont render controls by default', t => { 60 | const props = {}; 61 | const wrapper = shallow(); 62 | const items = wrapper.find('.msts__side_controls'); 63 | t.is(items.length, 0); 64 | }); 65 | 66 | test('render filter', t => { 67 | const props = { 68 | searchable: true 69 | }; 70 | const wrapper = shallow(); 71 | const items = wrapper.find('.msts__subheading'); 72 | t.is(items.type(), 'div'); 73 | t.true(items.hasClass('msts__subheading')); 74 | }); 75 | 76 | test('dont render filter by default', t => { 77 | const props = {}; 78 | const wrapper = shallow(); 79 | const items = wrapper.find('.msts__subheading'); 80 | t.is(items.length, 0); 81 | }); 82 | 83 | test('render filter clear', t => { 84 | const props = { 85 | searchable: true 86 | }; 87 | const wrapper = mount(); 88 | const filters = wrapper.find('.msts__filter-input'); 89 | 90 | t.is(wrapper.find('.msts__filter-clear').length, 0, 'dont render if filter is empty'); 91 | 92 | filters.at(0).simulate('change', {target: {value: 'foo'}}); 93 | t.is(wrapper.find('.msts__filter-clear').length, 1); 94 | 95 | filters.at(1).simulate('change', {target: {value: 'foo'}}); 96 | t.is(wrapper.find('.msts__filter-clear').length, 2); 97 | 98 | const filterClear = wrapper.find('.msts__filter-clear'); 99 | t.is(filterClear.length, 2); 100 | t.is(filterClear.at(0).type(), 'span'); 101 | t.true(filterClear.at(0).hasClass('msts__filter-clear')); 102 | }); 103 | 104 | test('custom `filterBy` property', t => { 105 | const props = { 106 | searchable: true, 107 | // Custom case-sensitive filter for test 108 | filterBy: (item, filter, labelKey) => item[labelKey].indexOf(filter) > -1, 109 | options: [ 110 | {label: 'Foo', value: 0}, 111 | {label: 'foo', value: 1}, 112 | {label: 'Bar', value: 2}, 113 | {label: 'bar', value: 3} 114 | ], 115 | value: [2, 3] 116 | }; 117 | const wrapper = mount(); 118 | const filters = wrapper.find('.msts__filter-input'); 119 | 120 | filters.at(0).simulate('change', {target: {value: 'Fo'}}); 121 | const available = wrapper.find('.msts__side_available'); 122 | t.is(available.find('.msts__list-item').length, 1); 123 | t.is(available.find('.msts__list-item').text(), 'Foo'); 124 | 125 | filters.at(1).simulate('change', {target: {value: 'Ba'}}); 126 | const selected = wrapper.find('.msts__side_selected'); 127 | t.is(selected.find('.msts__list-item').length, 1); 128 | t.is(selected.find('.msts__list-item').text(), 'Bar'); 129 | }); 130 | 131 | test('dont render filter clear if `clearable` is false', t => { 132 | const props = { 133 | searchable: true, 134 | clearable: false 135 | }; 136 | const wrapper = mount(); 137 | const filters = wrapper.find('.msts__filter-input'); 138 | 139 | filters.at(0).simulate('change', {target: {value: 'foo'}}); 140 | t.is(wrapper.find('.msts__filter-clear').length, 0); 141 | }); 142 | 143 | test('`disabled`: disable controls', t => { 144 | const props = { 145 | showControls: true, 146 | disabled: true, 147 | options: [ 148 | {label: 'Foo', value: 0}, 149 | {label: 'Bar', value: 1} 150 | ], 151 | value: [1] 152 | }; 153 | const wrapper = mount(); 154 | const controls = wrapper.find('.msts__control'); 155 | 156 | t.is(controls.get(0).props.disabled, true); 157 | t.is(controls.get(1).props.disabled, true); 158 | }); 159 | 160 | test('`disabled`: disable filter', t => { 161 | const props = { 162 | searchable: true, 163 | disabled: true 164 | }; 165 | const wrapper = mount(); 166 | const filters = wrapper.find('.msts__filter-input'); 167 | 168 | t.is(filters.get(0).props.disabled, true); 169 | t.is(filters.get(1).props.disabled, true); 170 | 171 | filters.at(0).simulate('change', {target: {value: 'foo'}}); 172 | filters.at(1).simulate('change', {target: {value: 'foo'}}); 173 | 174 | t.is(wrapper.find('.msts__filter-clear').length, 0, 'dont render clear buttons'); 175 | }); 176 | 177 | test('`disabled`: disable component', t => { 178 | const props = { 179 | disabled: true 180 | }; 181 | const wrapper = shallow(); 182 | t.true(wrapper.hasClass('msts msts_disabled')); 183 | }); 184 | 185 | test('`disabled`: disable handle', t => { 186 | let isChanged = false; 187 | const props = { 188 | disabled: true, 189 | options: [ 190 | {label: 'Foo', value: 0} 191 | ], 192 | onChange() { 193 | isChanged = true; 194 | } 195 | }; 196 | const wrapper = mount(); 197 | const items = wrapper.find('.msts__list-item'); 198 | items.simulate('click'); 199 | t.false(isChanged); 200 | }); 201 | 202 | test('`disabled`: disable option', t => { 203 | let isChanged = false; 204 | const props = { 205 | options: [ 206 | {label: 'Foo', value: 0, disabled: true} 207 | ], 208 | onChange() { 209 | isChanged = true; 210 | } 211 | }; 212 | const wrapper = mount(); 213 | const items = wrapper.find('.msts__list-item'); 214 | items.simulate('click'); 215 | t.false(isChanged); 216 | t.true(items.hasClass('msts__list-item')); 217 | t.true(items.hasClass('msts__list-item_disabled')); 218 | }); 219 | 220 | test('dont select disabled option by select all', t => { 221 | let value = []; 222 | const props = { 223 | showControls: true, 224 | options: [ 225 | {label: 'Foo', value: 0, disabled: true}, 226 | {label: 'Bar', value: 1} 227 | ], 228 | onChange(newValue) { 229 | value = newValue; 230 | } 231 | }; 232 | const wrapper = mount(); 233 | const selectAll = wrapper.find('.msts__control_select-all'); 234 | selectAll.simulate('click'); 235 | t.deepEqual(value, [1]); 236 | }); 237 | 238 | test('`highlight`: highlight option', t => { 239 | const props = { 240 | options: [ 241 | {label: 'Foo', value: 0}, 242 | {label: 'Bar', value: 1} 243 | ], 244 | highlight: [1] 245 | }; 246 | const wrapper = mount(); 247 | const items = wrapper.find('.msts__list-item'); 248 | t.is(items.at(0).props().className, 'msts__list-item'); 249 | t.is(items.at(1).props().className, 'msts__list-item msts__list-item_highlighted'); 250 | }); 251 | 252 | test('prop clearFilterText', t => { 253 | const props = { 254 | searchable: true, 255 | clearFilterText: 'Foo' 256 | }; 257 | const wrapper = mount(); 258 | const filters = wrapper.find('.msts__filter-input'); 259 | 260 | filters.at(0).simulate('change', {target: {value: 'foo'}}); 261 | filters.at(1).simulate('change', {target: {value: 'foo'}}); 262 | 263 | const filterClear = wrapper.find('.msts__filter-clear'); 264 | t.is(filterClear.get(0).props.title, 'Foo'); 265 | t.is(filterClear.get(1).props.title, 'Foo'); 266 | }); 267 | 268 | test('prop selectAllText and deselectAllText', t => { 269 | const props = { 270 | showControls: true, 271 | selectAllText: 'Foo', 272 | deselectAllText: 'Bar' 273 | }; 274 | const wrapper = mount(); 275 | 276 | const selectAll = wrapper.find('.msts__control_select-all'); 277 | t.is(selectAll.get(0).props.title, 'Foo'); 278 | 279 | const deselectAll = wrapper.find('.msts__control_deselect-all'); 280 | t.is(deselectAll.get(0).props.title, 'Bar'); 281 | }); 282 | 283 | test('prop labelKey and valueKey', t => { 284 | let value = []; 285 | const props = { 286 | labelKey: 'foo', 287 | valueKey: 'bar', 288 | options: [ 289 | {foo: 'Foo', bar: 3}, 290 | {foo: 'Bar', bar: 4} 291 | ], 292 | value: [4], 293 | onChange(newValue) { 294 | value = newValue; 295 | } 296 | }; 297 | const wrapper = mount(); 298 | const items = wrapper.find('.msts__list-item'); 299 | 300 | t.is(items.length, 2); 301 | t.is(items.get(0).props.children, 'Foo'); 302 | 303 | items.at(0).simulate('click'); 304 | t.deepEqual(value, [4, 3]); 305 | }); 306 | 307 | test('prop labelKey and valueKey controls', t => { 308 | let value = []; 309 | const props = { 310 | labelKey: 'foo', 311 | valueKey: 'bar', 312 | showControls: true, 313 | options: [ 314 | {foo: 'Foo', bar: 3}, 315 | {foo: 'Bar', bar: 4} 316 | ], 317 | value: [4], 318 | onChange(newValue) { 319 | value = newValue; 320 | } 321 | }; 322 | const wrapper = mount(); 323 | 324 | const selectAll = wrapper.find('.msts__control_select-all'); 325 | selectAll.at(0).simulate('click'); 326 | t.deepEqual(value, [3, 4]); 327 | 328 | const deselectAll = wrapper.find('.msts__control_deselect-all'); 329 | deselectAll.at(0).simulate('click'); 330 | t.deepEqual(value, []); 331 | }); 332 | 333 | test('prop labelKey and valueKey filterAvailable', t => { 334 | const props = { 335 | labelKey: 'foo', 336 | valueKey: 'bar', 337 | searchable: true, 338 | options: [ 339 | {foo: 'Foo', bar: 3} 340 | ] 341 | }; 342 | const wrapper = mount(); 343 | const filters = wrapper.find('.msts__filter-input'); 344 | 345 | t.is(wrapper.find('.msts__list-item').length, 1); 346 | 347 | filters.at(0).simulate('change', {target: {value: 'f'}}); 348 | t.is(wrapper.find('.msts__list-item').length, 1); 349 | 350 | filters.at(0).simulate('change', {target: {value: 'fb'}}); 351 | t.is(wrapper.find('.msts__list-item').length, 0); 352 | }); 353 | 354 | test('prop labelKey and valueKey filterSelected', t => { 355 | const props = { 356 | labelKey: 'foo', 357 | valueKey: 'bar', 358 | searchable: true, 359 | options: [ 360 | {foo: 'Foo', bar: 3} 361 | ], 362 | value: [3] 363 | }; 364 | const wrapper = mount(); 365 | 366 | t.is(wrapper.find('.msts__list-item').length, 1); 367 | 368 | const filters = wrapper.find('.msts__filter-input'); 369 | 370 | filters.at(1).simulate('change', {target: {value: 'f'}}); 371 | t.is(wrapper.find('.msts__list-item').length, 1, '`f`'); 372 | 373 | filters.at(1).simulate('change', {target: {value: 'fb'}}); 374 | t.is(wrapper.find('.msts__list-item').length, 0, '`fb`'); 375 | }); 376 | 377 | test('limit list', t => { 378 | let isChanged = false; 379 | const props = { 380 | options: [ 381 | {label: 'Foo', value: 0}, 382 | {label: 'Bar', value: 1}, 383 | {label: 'Baz', value: 2}, 384 | {label: 'Qux', value: 3}, 385 | {label: 'Quux', value: 4} 386 | ], 387 | limit: 3, 388 | value: [0, 1, 2], 389 | onChange() { 390 | isChanged = true; 391 | } 392 | }; 393 | const wrapper = mount(); 394 | const items = wrapper.find('.msts__list-item'); 395 | 396 | items.at(0).simulate('click'); 397 | 398 | t.is(items.at(0).props().className, 'msts__list-item msts__list-item_disabled'); 399 | t.is(items.at(0).text(), 'Qux'); 400 | t.is(items.at(1).props().className, 'msts__list-item msts__list-item_disabled'); 401 | t.is(items.at(1).text(), 'Quux'); 402 | t.false(isChanged); 403 | }); 404 | 405 | test('limit list selectall', t => { 406 | let value = []; 407 | const props = { 408 | showControls: true, 409 | options: [ 410 | {label: 'Foo', value: 0}, 411 | {label: 'Bar', value: 1, disabled: true}, 412 | {label: 'Baz', value: 2}, 413 | {label: 'Qux', value: 3}, 414 | {label: 'Quux', value: 4} 415 | ], 416 | value: [4], 417 | limit: 3, 418 | onChange(newValue) { 419 | value = newValue; 420 | } 421 | }; 422 | const wrapper = mount(); 423 | const selectAll = wrapper.find('.msts__control_select-all'); 424 | selectAll.simulate('click'); 425 | t.deepEqual(value, [0, 2, 4]); 426 | }); 427 | 428 | test('selectall filtered items only', t => { 429 | let value = []; 430 | const props = { 431 | searchable: true, 432 | showControls: true, 433 | options: [ 434 | {label: 'Foo', value: 0}, 435 | {label: 'Bar', value: 1}, 436 | {label: 'Baz', value: 2}, 437 | {label: 'Qux', value: 3}, 438 | {label: 'Quux', value: 4} 439 | ], 440 | value: [0], 441 | onChange(newValue) { 442 | value = newValue; 443 | } 444 | }; 445 | const wrapper = mount(); 446 | const filters = wrapper.find('.msts__filter-input'); 447 | const selectAll = wrapper.find('.msts__control_select-all'); 448 | 449 | filters.at(0).simulate('change', {target: {value: 'ux'}}); 450 | 451 | selectAll.simulate('click'); 452 | 453 | t.deepEqual(value, [0, 3, 4]); 454 | }); 455 | 456 | test('deselectall filtered items only', t => { 457 | let value = []; 458 | const props = { 459 | searchable: true, 460 | showControls: true, 461 | options: [ 462 | {label: 'Foo', value: 0}, 463 | {label: 'Bar', value: 1}, 464 | {label: 'Baz', value: 2}, 465 | {label: 'Qux', value: 3}, 466 | {label: 'Quux', value: 4} 467 | ], 468 | value: [0, 1, 2, 3, 4], 469 | onChange(newValue) { 470 | value = newValue; 471 | } 472 | }; 473 | const wrapper = mount(); 474 | const filters = wrapper.find('.msts__filter-input'); 475 | const deselectAll = wrapper.find('.msts__control_deselect-all'); 476 | 477 | filters.at(1).simulate('change', {target: {value: 'ux'}}); 478 | 479 | deselectAll.simulate('click'); 480 | 481 | t.deepEqual(value, [0, 1, 2]); 482 | }); 483 | --------------------------------------------------------------------------------