├── jestEnvironment.js
├── src
├── styles
│ ├── index.js
│ └── PowerSelect.scss
├── index.js
├── RenderOption.js
├── Dropdown.js
├── SelectTrigger.js
├── AutoResizeInput.js
├── PowerSelect
│ ├── BeforeOptionsWrapper.js
│ ├── index.js
│ └── __tests__
│ │ └── PowerSelect-test.js
├── __tests__
│ ├── test-utils
│ │ ├── constants.js
│ │ └── create-page-object.js
│ └── utils
│ │ └── matcher-test.js
├── Option.js
├── TriggerWrapper.js
├── PowerSelectMultiple
│ ├── SelectedOption.js
│ ├── SelectTrigger.js
│ ├── index.js
│ └── __tests__
│ │ └── PowerSelectMultiple-test.js
├── TypeAhead
│ ├── index.js
│ ├── SelectTrigger.js
│ └── __tests__
│ │ └── TypeAhead-test.js
├── DropdownMenu.js
├── Options.js
├── utils.js
└── Select.js
├── stories
├── index.stories.js
├── utils
│ ├── Centered.js
│ └── constants.js
├── Recipes.stories.js
├── Recipes
│ ├── HighlightSearch.js
│ └── TaggedInput.js
├── PowerSelectTypeAhead.stories.js
├── PowerSelectMultiple.stories.js
├── index.scss
└── PowerSelect.stories.js
├── .babelrc
├── .storybook
├── preview-head.html
├── addons.js
├── config.js
└── webpack.config.js
├── .travis.yml
├── .gitignore
├── .editorconfig
├── loaders
└── snippet-loader.js
├── testSetup.js
├── LICENSE
├── webpack.config.js
├── README.md
└── package.json
/jestEnvironment.js:
--------------------------------------------------------------------------------
1 | require("babel-polyfill");
2 |
--------------------------------------------------------------------------------
/src/styles/index.js:
--------------------------------------------------------------------------------
1 | import './PowerSelect.scss';
2 |
--------------------------------------------------------------------------------
/stories/index.stories.js:
--------------------------------------------------------------------------------
1 | import 'src/styles';
2 | import './index.scss';
3 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "es2015",
4 | "react",
5 | "stage-0"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/.storybook/preview-head.html:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 | node_js:
4 | - "14"
5 | - "16"
6 | script:
7 | - npm test
8 | after_success: 'npm run coveralls'
9 |
--------------------------------------------------------------------------------
/.storybook/addons.js:
--------------------------------------------------------------------------------
1 | import '@storybook/addon-knobs/register';
2 | import '@storybook/addon-actions/register';
3 | import '@storybook/addon-options/register';
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # output
2 | lib/
3 | dist/
4 | coverage/
5 |
6 | # dependencies
7 | /node_modules
8 | /bower_components
9 |
10 | # misc
11 | npm-debug.log
12 | .DS_Store
13 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import PowerSelect from './PowerSelect';
2 | import TypeAhead from './TypeAhead';
3 | import TypeAheadSelectTrigger from './TypeAhead/SelectTrigger';
4 | import PowerSelectMultiple from './PowerSelectMultiple';
5 |
6 | TypeAhead.Trigger = TypeAheadSelectTrigger;
7 |
8 | export { PowerSelect, TypeAhead, PowerSelectMultiple };
9 |
--------------------------------------------------------------------------------
/stories/utils/Centered.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default story => (
4 |
14 | );
15 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig helps developers define and maintain consistent
2 | # coding styles between different editors and IDEs
3 | # editorconfig.org
4 |
5 | root = true
6 |
7 |
8 | [*]
9 | end_of_line = lf
10 | charset = utf-8
11 | trim_trailing_whitespace = true
12 | insert_final_newline = true
13 | indent_style = space
14 | indent_size = 2
15 |
16 | [*.hbs]
17 | insert_final_newline = false
18 |
19 | [*.{diff,md}]
20 | trim_trailing_whitespace = false
21 |
--------------------------------------------------------------------------------
/loaders/snippet-loader.js:
--------------------------------------------------------------------------------
1 | function capitalize(string) {
2 | return string.charAt(0).toUpperCase() + string.slice(1);
3 | }
4 |
5 | module.exports = function(source) {
6 | var path = this.resourcePath;
7 | var splits = path.split('/');
8 | var namedParts = splits.slice(splits.length - 2);
9 | var snippetName =
10 | capitalize(namedParts[0]) + capitalize(namedParts[1].replace('.js', ''));
11 | return `global['${snippetName}'] = ${JSON.stringify(source)}`;
12 | };
13 |
--------------------------------------------------------------------------------
/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import { configure } from '@storybook/react';
2 | import { setOptions } from '@storybook/addon-options';
3 |
4 | setOptions({
5 | name: 'React Power Select',
6 | url: 'https://github.com/selvagsz/react-power-select',
7 | addonPanelInRight: true,
8 | enableShortcuts: true,
9 | });
10 |
11 | // automatically import all files ending in *.stories.js
12 | const req = require.context('../stories', true, /.stories.js$/);
13 | function loadStories() {
14 | req.keys().forEach((filename) => req(filename));
15 | }
16 |
17 | configure(loadStories, module);
18 |
--------------------------------------------------------------------------------
/stories/Recipes.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import { withKnobs } from '@storybook/addon-knobs/react';
4 | import Centered from './utils/Centered';
5 |
6 | import HighlightSearch from './Recipes/HighlightSearch';
7 | import TaggedInput from './Recipes/TaggedInput';
8 |
9 | const stories = storiesOf('More Recipes', module);
10 | stories.addDecorator(withKnobs);
11 | stories.addDecorator(Centered);
12 |
13 | stories.add('highlight search', () => );
14 | stories.add('tagged input', () => );
15 |
--------------------------------------------------------------------------------
/testSetup.js:
--------------------------------------------------------------------------------
1 | import 'core-js/es6/map';
2 | import 'core-js/es6/set';
3 | import 'raf/polyfill';
4 | import Enzyme from 'enzyme';
5 | import Adapter from 'enzyme-adapter-react-16';
6 | import $ from 'jquery';
7 |
8 | $.prototype.exists = function() {
9 | return !!this.length;
10 | };
11 |
12 | $.prototype.childAt = function(index) {
13 | return this.children().eq(index);
14 | };
15 |
16 | $.prototype.simulate = function(event, params) {
17 | this.trigger(event, params);
18 | return this;
19 | };
20 |
21 | $.prototype.at = function(index) {
22 | return this.eq(index);
23 | };
24 |
25 | Enzyme.configure({ adapter: new Adapter() });
26 |
--------------------------------------------------------------------------------
/src/RenderOption.js:
--------------------------------------------------------------------------------
1 | import React, { Component, isValidElement, cloneElement } from 'react';
2 |
3 | export default function RenderOption({ option, select, optionLabelPath, optionComponent }) {
4 | let publicProps = { option, select, optionLabelPath };
5 | let OptionComponent = optionComponent;
6 | if (isValidElement(OptionComponent)) {
7 | return cloneElement(OptionComponent, publicProps);
8 | }
9 | if (OptionComponent) {
10 | return ;
11 | }
12 | if (typeof option === 'object') {
13 | if (optionLabelPath) {
14 | return {option[optionLabelPath]} ;
15 | }
16 | }
17 | if (typeof option === 'string') {
18 | return {option} ;
19 | }
20 | return null;
21 | }
22 |
--------------------------------------------------------------------------------
/src/Dropdown.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import TetherComponent from 'react-tether';
3 | import cx from 'classnames';
4 |
5 | export default class Dropdown extends Component {
6 | render() {
7 | let { className, horizontalPosition, children } = this.props;
8 | return (
9 |
20 | {children}
21 |
22 | );
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/SelectTrigger.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import RenderOption from './RenderOption';
3 | import TriggerWrapper from './TriggerWrapper';
4 |
5 | export default function Trigger({
6 | selectedOption,
7 | optionLabelPath,
8 | selectedOptionComponent,
9 | placeholder,
10 | select,
11 | ...rest
12 | }) {
13 | return (
14 |
15 |
16 | {selectedOption ? (
17 |
23 | ) : (
24 | {placeholder}
25 | )}
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/AutoResizeInput.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | export default class AutoResizeInput extends Component {
4 | state = {};
5 |
6 | componentWillMount() {
7 | this.setInputSize(this.props.value);
8 | }
9 |
10 | componentWillReceiveProps(nextProps) {
11 | this.setInputSize(nextProps.value);
12 | }
13 |
14 | setInputSize(value) {
15 | this.setState({
16 | length: value.length + 4,
17 | });
18 | }
19 |
20 | setInputRef(input) {
21 | this.input = input;
22 |
23 | // Focus input if autoFocus passed
24 | if (this.props.autoFocus && this.input) {
25 | this.input.focus();
26 | }
27 | }
28 |
29 | render() {
30 | let { autoFocus, ...rest } = this.props;
31 | return this.setInputRef(input)} size={this.state.length} {...rest} />;
32 | }
33 | }
34 |
35 | AutoResizeInput.defaultProps = {
36 | onChange: () => {},
37 | value: '',
38 | };
39 |
--------------------------------------------------------------------------------
/src/PowerSelect/BeforeOptionsWrapper.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import AutoResizeInput from '../AutoResizeInput';
3 |
4 | export default function BeforeOptionsWrapper({
5 | searchEnabled,
6 | onChange,
7 | beforeOptionsComponent,
8 | searchPlaceholder,
9 | searchInputAutoFocus,
10 | ...otherProps
11 | }) {
12 | let BeforeOptionsComponent = beforeOptionsComponent;
13 | return (
14 |
15 | {searchEnabled && (
16 |
25 | )}
26 | {beforeOptionsComponent &&
}
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/__tests__/test-utils/constants.js:
--------------------------------------------------------------------------------
1 | export const KEY_CODES = {
2 | UP_ARROW: 38,
3 | DOWN_ARROW: 40,
4 | ESCAPE: 27,
5 | ENTER: 13,
6 | TAB: 9,
7 | };
8 |
9 | export const frameworks = ['React', 'Ember', 'Angular', 'Vue', 'Inferno'];
10 | export const countries = [
11 | {
12 | name: 'Argentina',
13 | code: 'ARG',
14 | flag: 'https://flagcdn.com/ar.svg',
15 | continent: 'South America',
16 | },
17 | {
18 | name: 'Brazil',
19 | code: 'BRA',
20 | flag: 'https://flagcdn.com/br.svg',
21 | continent: 'South America',
22 | },
23 | {
24 | name: 'Canada',
25 | code: 'CAN',
26 | flag: 'https://flagcdn.com/ca.svg',
27 | continent: 'North America',
28 | },
29 | {
30 | name: 'China',
31 | code: 'CHN',
32 | flag: 'https://flagcdn.com/cn.svg',
33 | continent: 'Asia',
34 | },
35 | {
36 | name: 'India',
37 | code: 'IND',
38 | flag: 'https://flagcdn.com/in.svg',
39 | continent: 'Asia',
40 | },
41 | ];
42 |
--------------------------------------------------------------------------------
/src/Option.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import cx from 'classnames';
3 | import RenderOption from './RenderOption';
4 |
5 | export default class Option extends Component {
6 | render() {
7 | let {
8 | option,
9 | select,
10 | disabled,
11 | optionIndex,
12 | optionLabelPath,
13 | optionComponent,
14 | isHighlighted,
15 | onOptionClick,
16 | } = this.props;
17 | let isDisabled = disabled || option.disabled;
18 | return (
19 |
27 |
33 |
34 | );
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/TriggerWrapper.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import cx from 'classnames';
3 | import { renderComponent } from './utils';
4 |
5 | export default function TriggerWrapper({
6 | value,
7 | select,
8 | showClear,
9 | onClearClick,
10 | triggerLHSComponent,
11 | triggerRHSComponent,
12 | children,
13 | }) {
14 | return (
15 |
20 | {triggerLHSComponent && (
21 |
22 | {renderComponent(triggerLHSComponent, { select })}
23 |
24 | )}
25 |
26 | {children}
27 |
28 | {showClear &&
}
29 |
30 | {triggerRHSComponent && (
31 |
32 | {renderComponent(triggerRHSComponent, { select })}
33 |
34 | )}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2016 Selva Ganesh
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/PowerSelect/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Select from '../Select';
3 | import BeforeOptionsWrapper from './BeforeOptionsWrapper';
4 |
5 | export default class PowerSelect extends Component {
6 | handleSearchInputChange = event => {
7 | // hackish
8 | this.select.handleSearchInputChange(event);
9 | };
10 |
11 | render() {
12 | let {
13 | searchEnabled,
14 | searchPlaceholder,
15 | beforeOptionsComponent,
16 | searchInputAutoFocus,
17 | ...rest
18 | } = this.props;
19 | return (
20 | (this.select = select)}
22 | beforeOptionsComponent={
23 |
30 | }
31 | {...rest}
32 | />
33 | );
34 | }
35 | }
36 |
37 | PowerSelect.displayName = 'PowerSelect';
38 | PowerSelect.defaultProps = {
39 | searchEnabled: true,
40 | searchInputAutoFocus: true,
41 | };
42 |
--------------------------------------------------------------------------------
/src/PowerSelectMultiple/SelectedOption.js:
--------------------------------------------------------------------------------
1 | import React, { isValidElement, cloneElement } from 'react';
2 | import { renderComponent } from '../utils';
3 |
4 | export default function SelectedOption(props) {
5 | let {
6 | option,
7 | optionLabelPath,
8 | selectedOptionComponent,
9 | showOptionClose,
10 | onCloseClick,
11 | select,
12 | } = props;
13 | let value = null;
14 | if (typeof option === 'object') {
15 | if (optionLabelPath) {
16 | value = option[optionLabelPath];
17 | }
18 | }
19 | if (typeof option === 'string') {
20 | value = option;
21 | }
22 | return (
23 |
24 |
25 | {selectedOptionComponent ? (
26 | renderComponent(selectedOptionComponent, { option, select })
27 | ) : (
28 | value
29 | )}
30 |
31 | {showOptionClose ? (
32 | {
35 | event.stopPropagation();
36 | onCloseClick({ option, select });
37 | }}
38 | >
39 | ×
40 |
41 | ) : null}
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/stories/Recipes/HighlightSearch.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { PowerSelect } from 'src';
3 | import { countries } from '../utils/constants';
4 |
5 | const createHighlighedOption = (label, searchTerm) => {
6 | if (searchTerm) {
7 | let escapedSearchTerm = searchTerm.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
8 | label = label.replace(new RegExp(escapedSearchTerm, 'i'), '$& ');
9 | }
10 | return {
11 | __html: label,
12 | };
13 | };
14 |
15 | const HighlightedOption = ({ option, select, optionLabelPath }) => {
16 | let highlightedLabel = option[optionLabelPath];
17 | return (
18 |
19 | );
20 | };
21 |
22 | export default class HighlightSearchDemo extends Component {
23 | state = {
24 | selectedCountry: null,
25 | };
26 |
27 | handleChange = ({ option }) => {
28 | this.setState({ selectedCountry: option });
29 | };
30 |
31 | render() {
32 | return (
33 | }
38 | onChange={this.handleChange}
39 | placeholder="Select your country"
40 | />
41 | );
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/TypeAhead/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import cx from 'classnames';
3 | import Select from '../Select';
4 | import SelectTrigger from './SelectTrigger';
5 |
6 | export default class TypeAhead extends Component {
7 | handleKeyDown = (event, { select }) => {
8 | let { onKeyDown, onChange } = this.props;
9 | if (event.which === 27) {
10 | if (!select.searchTerm) {
11 | onChange({
12 | option: undefined,
13 | select,
14 | });
15 | }
16 | }
17 | if (onKeyDown) {
18 | onKeyDown(event, { select });
19 | }
20 | };
21 |
22 | render() {
23 | let { className, selectedOptionLabelPath, onKeyDown, ...rest } = this.props;
24 | const TriggerComponent = this.props.triggerComponent;
25 |
26 | return (
27 | (this.select = select)}
30 | triggerComponent={props => {
31 | return ;
32 | }}
33 | selectedOptionLabelPath={selectedOptionLabelPath}
34 | {...rest}
35 | onKeyDown={this.handleKeyDown}
36 | />
37 | );
38 | }
39 | }
40 |
41 | TypeAhead.displayName = 'TypeAhead';
42 | TypeAhead.defaultProps = {
43 | triggerComponent: SelectTrigger,
44 | };
45 |
--------------------------------------------------------------------------------
/.storybook/webpack.config.js:
--------------------------------------------------------------------------------
1 | // you can use this file to add your custom webpack plugins, loaders and anything you like.
2 | // This is just the basic way to add additional webpack configurations.
3 | // For more information refer the docs: https://storybook.js.org/configurations/custom-webpack-config
4 |
5 | // IMPORTANT
6 | // When you add this file, we won't add the default configurations which is similar
7 | // to "React Create App". This only has babel loader to load JavaScript.
8 |
9 | const path = require('path');
10 |
11 | module.exports = {
12 | context: process.cwd(),
13 | output: {
14 | path: path.resolve(process.cwd(), 'docs'),
15 | },
16 | resolve: {
17 | extensions: ['.js', '.jsx', '.scss'],
18 | modules: [
19 | path.resolve(process.cwd(), '.'),
20 | path.resolve(process.cwd(), 'node_modules'),
21 | path.resolve(process.cwd(), 'node_modules', 'velocity-react', 'node_modules'),
22 | ],
23 | },
24 |
25 | plugins: [
26 | // your custom plugins
27 | ],
28 |
29 | module: {
30 | rules: [
31 | // add your custom rules.
32 | {
33 | test: /\.(css|scss)$/,
34 | use: [
35 | {
36 | loader: 'style-loader',
37 | },
38 | {
39 | loader: 'css-loader',
40 | options: {
41 | minimize: true,
42 | },
43 | },
44 | {
45 | loader: 'sass-loader',
46 | },
47 | ],
48 | },
49 | ],
50 | },
51 | };
52 |
--------------------------------------------------------------------------------
/stories/PowerSelectTypeAhead.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useState } from 'react';
3 | import { storiesOf } from '@storybook/react';
4 | import { withKnobs } from '@storybook/addon-knobs/react';
5 | import { text, array, boolean, object } from '@storybook/addon-knobs/react';
6 | import { action } from '@storybook/addon-actions';
7 | import { TypeAhead } from 'src';
8 | import Centered from './utils/Centered';
9 | import { countries } from './utils/constants';
10 |
11 | const TypeAheadWithHooks = () => {
12 | const [selected, setCountry] = useState(countries[8]);
13 | return (
14 | {
18 | action('onChange')(args);
19 | setCountry(args.option);
20 | }}
21 | optionLabelPath={text('optionLabelPath', 'name')}
22 | placeholder={text('placeholder', 'Select your favorite country')}
23 | disabled={boolean('disabled', false)}
24 | showClear={boolean('showClear', true)}
25 | searchPlaceholder={text('searchPlaceholder', 'Search...')}
26 | searchIndices={array('searchIndices', ['name', 'code'])}
27 | optionComponent={({ option }) => (
28 |
29 |
30 | {option.name} ({option.code})
31 |
32 | )}
33 | />
34 | );
35 | };
36 |
37 | const stories = storiesOf('Power Select TypeAhead', module);
38 | stories.addDecorator(withKnobs);
39 | stories.addDecorator(Centered);
40 |
41 | stories.add('with configs & custom option component', () => );
42 |
--------------------------------------------------------------------------------
/src/DropdownMenu.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import cx from 'classnames';
3 | import { renderComponent } from './utils';
4 | import Options from './Options';
5 |
6 | export default class DropdownMenu extends Component {
7 | componentWillMount() {
8 | this.validateAndClose(this.props.options);
9 | }
10 |
11 | componentWillReceiveProps({ options }) {
12 | this.validateAndClose(options);
13 | }
14 |
15 | validateAndClose(options) {
16 | let { beforeOptionsComponent, afterOptionsComponent, select } = this.props;
17 | if (!beforeOptionsComponent && !afterOptionsComponent && !options.length) {
18 | select.actions.close();
19 | }
20 | }
21 |
22 | render() {
23 | let {
24 | className,
25 | select,
26 | handleKeyDown,
27 | highlightedOption,
28 | minWidth,
29 | onRef,
30 | beforeOptionsComponent,
31 | afterOptionsComponent,
32 | ...otherProps
33 | } = this.props;
34 | return (
35 | {
39 | handleKeyDown(event, highlightedOption);
40 | }}
41 | style={{ minWidth }}
42 | ref={dropdownMenu => this.props.onRef(dropdownMenu)}
43 | >
44 | {beforeOptionsComponent && renderComponent(beforeOptionsComponent, { select })}
45 |
46 |
47 |
48 | {afterOptionsComponent && renderComponent(afterOptionsComponent, { select })}
49 |
50 | );
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 | const ExtractTextPlugin = require('extract-text-webpack-plugin');
4 |
5 | const SRC_DIR = `${__dirname}/src`;
6 | const DIST_DIR = `${__dirname}/dist`;
7 |
8 | module.exports = {
9 | context: SRC_DIR,
10 | resolve: {
11 | extensions: ['.js', '.scss'],
12 | },
13 |
14 | entry: {
15 | 'react-power-select': './index.js',
16 | css: './styles/index.js',
17 | },
18 |
19 | output: {
20 | path: `${DIST_DIR}/`,
21 | filename: '[name].js',
22 | },
23 |
24 | module: {
25 | rules: [
26 | {
27 | test: /\.js$/,
28 | exclude: /node_modules/,
29 | use: [
30 | {
31 | loader: 'babel-loader',
32 | options: {
33 | cacheDirectory: true,
34 | presets: ['es2015', 'react', 'stage-0'],
35 | },
36 | },
37 | ],
38 | },
39 | {
40 | test: /\.scss$/,
41 | use: ExtractTextPlugin.extract({
42 | fallback: 'style-loader',
43 | use: [
44 | {
45 | loader: 'css-loader',
46 | options: {
47 | minimize: true,
48 | },
49 | },
50 | {
51 | loader: 'sass-loader',
52 | },
53 | ],
54 | }),
55 | },
56 | ],
57 | },
58 |
59 | plugins: [
60 | new webpack.ProvidePlugin({
61 | React: 'react',
62 | }),
63 |
64 | new webpack.optimize.UglifyJsPlugin({
65 | compress: {
66 | warnings: false,
67 | },
68 | output: {
69 | comments: false,
70 | },
71 | }),
72 |
73 | new ExtractTextPlugin({
74 | filename: 'react-power-select.css',
75 | }),
76 | ],
77 | };
78 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ### :warning: NOT ACTIVELY WORKING ON THE PROJECT. Looking for Maintainers
2 |
3 | # react-power-select
4 | A highly composable & reusable select components
5 |
6 | [](https://www.npmjs.com/package/react-power-select)
7 | [](https://travis-ci.com/selvagsz/react-power-select)
8 | [](https://coveralls.io/github/selvagsz/react-power-select)
9 | [](https://selvagsz.github.io/react-power-select)
10 |
11 |
12 | ### DEMO https://selvagsz.github.io/react-power-select/
13 |
14 | # Installation
15 |
16 | ```bash
17 | npm i react-power-select --save
18 | ```
19 |
20 | Import the CSS in your bundle
21 |
22 | ```js
23 | import 'react-power-select/dist/react-power-select.css'
24 | ```
25 |
26 | # Usage
27 |
28 | ```js
29 | import React, { Component } from 'react'
30 | import { PowerSelect } from 'react-power-select'
31 |
32 | export default class Demo extends Component {
33 | state = {};
34 |
35 | handleChange = ({ option }) => {
36 | this.setState({
37 | selectedOption: option
38 | })
39 | }
40 |
41 | render() {
42 | return (
43 |
48 | )
49 | }
50 | }
51 | ```
52 |
53 |
54 | ## Credits
55 |
56 | Hat tip to [ember-power-select's](https://github.com/cibernox/ember-power-select) [architecture](http://www.ember-power-select.com/EPS_disected-48ad0d36e579a5554bff1407b51c554b.png)
57 |
--------------------------------------------------------------------------------
/stories/utils/constants.js:
--------------------------------------------------------------------------------
1 | export const countries = [
2 | {
3 | name: 'Argentina',
4 | code: 'ARG',
5 | flag: 'https://flagcdn.com/ar.svg',
6 | continent: 'South America',
7 | },
8 | {
9 | name: 'Brazil',
10 | code: 'BRA',
11 | flag: 'https://flagcdn.com/br.svg',
12 | continent: 'South America',
13 | },
14 | {
15 | name: 'Canada',
16 | code: 'CAN',
17 | flag: 'https://flagcdn.com/ca.svg',
18 | continent: 'North America',
19 | },
20 | {
21 | name: 'China',
22 | code: 'CHN',
23 | flag: 'https://flagcdn.com/cn.svg',
24 | continent: 'Asia',
25 | },
26 | {
27 | name: 'India',
28 | code: 'IND',
29 | flag: 'https://flagcdn.com/in.svg',
30 | continent: 'Asia',
31 | },
32 | {
33 | name: 'Japan',
34 | code: 'JPN',
35 | flag: 'https://flagcdn.com/jp.svg',
36 | continent: 'Asia',
37 | },
38 | {
39 | name: 'Portugal',
40 | code: 'PRT',
41 | flag: 'https://flagcdn.com/pr.svg',
42 | continent: 'Europe',
43 | },
44 | {
45 | name: 'Russian Federation',
46 | code: 'RUS',
47 | flag: 'https://flagcdn.com/ru.svg',
48 | continent: 'Asia',
49 | },
50 | {
51 | name: 'Spain',
52 | code: 'ESP',
53 | flag: 'https://flagcdn.com/es.svg',
54 | continent: 'Europe',
55 | },
56 | {
57 | name: 'United Kingdom',
58 | code: 'GBR',
59 | flag: 'https://flagcdn.com/gb.svg',
60 | continent: 'Europe',
61 | },
62 | {
63 | name: 'United States of America',
64 | code: 'USA',
65 | flag: 'https://flagcdn.com/us.svg',
66 | continent: 'North America',
67 | },
68 | ];
69 |
70 | export const frameworks = [
71 | 'React',
72 | 'Ember',
73 | 'Angular',
74 | 'Vue',
75 | 'Preact',
76 | 'Meteor',
77 | 'Backbone',
78 | 'Knockout',
79 | 'SproutCore',
80 | 'Spine',
81 | ];
82 |
--------------------------------------------------------------------------------
/stories/PowerSelectMultiple.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useState } from 'react';
3 | import { storiesOf } from '@storybook/react';
4 | import { withKnobs } from '@storybook/addon-knobs/react';
5 | import { text, array, boolean, object } from '@storybook/addon-knobs/react';
6 | import { action } from '@storybook/addon-actions';
7 | import { countries } from './utils/constants';
8 | import PowerSelectMultiple from 'src/PowerSelectMultiple';
9 | import Centered from './utils/Centered';
10 |
11 | const PowerSelectMultipleWithHooks = props => {
12 | const [selected, setSelectedCountries] = useState([countries[8], countries[4]]);
13 | return (
14 | {
18 | action('onChange')(args);
19 | setSelectedCountries(args.options);
20 | }}
21 | optionLabelPath={text('optionLabelPath', 'name')}
22 | placeholder={text('placeholder', 'Select your favorite countries')}
23 | disabled={boolean('disabled', false)}
24 | showClear={boolean('showClear', true)}
25 | searchIndices={array('searchIndices', ['name', 'code'])}
26 | optionComponent={({ option }) => (
27 |
28 |
29 | {option.name} ({option.code})
30 |
31 | )}
32 | selectedOptionComponent={({ option, select }) => (
33 |
34 |
35 | {option.name}
36 |
37 | )}
38 | />
39 | );
40 | };
41 |
42 | const stories = storiesOf('Power Select Multiple', module);
43 | stories.addDecorator(withKnobs);
44 | stories.addDecorator(Centered);
45 |
46 | stories.add('with configs & custom option/selected component', () => (
47 |
48 | ));
49 |
--------------------------------------------------------------------------------
/stories/index.scss:
--------------------------------------------------------------------------------
1 | @mixin placeholder() {
2 | color: #ccc;
3 | font-weight: lighter;
4 | font-size: 14px;
5 | }
6 |
7 | *,
8 | *::before,
9 | *::after {
10 | box-sizing: border-box;
11 | margin: 0;
12 | }
13 |
14 | html {
15 | height: 100%;
16 | }
17 |
18 | body {
19 | font-family: 'Lato', sans-serif;
20 | padding-bottom: 100px;
21 | }
22 |
23 | html, body {
24 | background-color: rgba(249, 249, 249, 0.44);
25 | }
26 |
27 | ::-webkit-input-placeholder {
28 | @include placeholder()
29 | }
30 | ::-moz-placeholder {
31 | @include placeholder()
32 | }
33 | :-ms-input-placeholder {
34 | @include placeholder()
35 | }
36 | :-moz-placeholder {
37 | @include placeholder()
38 | }
39 |
40 | .flag {
41 | width: 20px;
42 | height: 13px;
43 | margin-right: 8px;
44 | }
45 |
46 | .quick-create {
47 | padding: 8px 12px;
48 | border-top: 1px solid #e5e5e5;
49 | cursor: pointer;
50 | display: block;
51 | color: #3260b7;
52 | background-color: #e5ebf6;
53 |
54 | &:hover {
55 | color: #0039a6;
56 | }
57 | }
58 |
59 | .options-header {
60 | padding: 5px 10px;
61 | text-transform: uppercase;
62 | font-size: 13px;
63 | letter-spacing: 5px;
64 | font-weight: bold;
65 | border-bottom: 1px dashed rgba(89, 96, 206, 0.5);
66 | margin-bottom: 10px;
67 | background-color: rgba(89, 96, 206, 0.25);
68 | border-top: 1px dashed rgba(89, 96, 206, 0.5);
69 | }
70 |
71 | .code {
72 | float: right;
73 | font-size: 11px;
74 | color: #666;
75 | letter-spacing: 2px;
76 | margin-top: 4px;
77 | }
78 |
79 | .highlight {
80 | background-color: yellow;
81 | font-weight: bold;
82 | }
83 |
84 | .help {
85 | color: #aaa;
86 | font-size: 13px;
87 | margin-top: 8px;
88 | }
89 |
90 | textarea#options {
91 | max-height: 200px;
92 | }
93 |
94 | .TaggedInput {
95 | // Hides the caret
96 | .PowerSelect__TriggerStatus {
97 | display: none;
98 | }
99 |
100 | .PowerSelect__Clear {
101 | padding-right: 8px;
102 | }
103 |
104 | // Removes the padding allocated for caret
105 | .PowerSelectMultiple__SelectedOptions {
106 | padding-right: 0;
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/stories/Recipes/TaggedInput.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { PowerSelectMultiple } from 'src';
3 |
4 | // Hide the caret via CSS
5 |
6 | // .TaggedInput {
7 | // // Hides the caret
8 | // .PowerSelect__TriggerStatus {
9 | // display: none;
10 | // }
11 |
12 | // // Removes the padding allocated for caret
13 | // .PowerSelectMultiple__SelectedOptions {
14 | // padding-right: 0;
15 | // }
16 | // }
17 |
18 | // TaggedInput Component - composed on top of PowerSelectMultiple
19 | class TaggedInput extends Component {
20 | handleChange = ({ options, select }) => {
21 | this.props.onChange({
22 | items: options,
23 | select,
24 | });
25 | };
26 |
27 | handleSearchInputChange = (value, select) => {
28 | if (value.length > 1 && value.charAt(value.length - 1) === ',') {
29 | let items = this.props.items.slice();
30 | items.push(value.slice(0, -1));
31 |
32 | this.props.onChange({
33 | items,
34 | select,
35 | });
36 |
37 | select.actions.search('');
38 | select.actions.focus();
39 | }
40 | };
41 |
42 | render() {
43 | let { items, onChange, onSearchInputChange, ...rest } = this.props;
44 | return (
45 | {
51 | this.handleSearchInputChange(event.target.value, select);
52 | if (onSearchInputChange) {
53 | onSearchInputChange((event, { select }));
54 | }
55 | }}
56 | {...rest}
57 | />
58 | );
59 | }
60 | }
61 |
62 | TaggedInput.defaultProps = {
63 | items: [],
64 | };
65 |
66 | export default class TaggedInputDemo extends Component {
67 | state = {
68 | urls: [],
69 | };
70 |
71 | handleChange = ({ items }) => {
72 | this.setState({ urls: items });
73 | };
74 |
75 | render() {
76 | return (
77 |
82 | );
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/TypeAhead/SelectTrigger.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import TriggerWrapper from '../TriggerWrapper';
3 | import AutoResizeInput from '../AutoResizeInput';
4 |
5 | export default class SelectTrigger extends Component {
6 | state = {};
7 |
8 | componentWillMount() {
9 | let value = this.getValueFromSelectedOption(this.props);
10 | this.setState({ value });
11 | }
12 |
13 | componentWillReceiveProps(nextProps) {
14 | let value =
15 | nextProps.searchTerm !== null
16 | ? nextProps.searchTerm
17 | : this.getValueFromSelectedOption(nextProps);
18 | this.setState({
19 | value,
20 | });
21 | }
22 |
23 | getValueFromSelectedOption(props = this.props) {
24 | let { selectedOption, selectedOptionLabelPath, optionLabelPath } = props;
25 | let value = '';
26 | selectedOptionLabelPath = selectedOptionLabelPath || optionLabelPath;
27 | if (selectedOption) {
28 | if (typeof selectedOption === 'string') {
29 | value = selectedOption;
30 | } else if (selectedOptionLabelPath) {
31 | value = selectedOption[selectedOptionLabelPath];
32 | }
33 | }
34 | return value;
35 | }
36 |
37 | handleInputChange = event => {
38 | this.setState({
39 | value: event.target.value,
40 | });
41 | this.props.handleOnChange(event);
42 | };
43 |
44 | render() {
45 | let {
46 | select,
47 | placeholder,
48 | disabled,
49 | autoFocus,
50 | handleOnChange,
51 | handleKeyDown,
52 | handleOnFocus,
53 | handleOnBlur,
54 | ...rest
55 | } = this.props;
56 | let value = this.state.value;
57 |
58 | return (
59 |
60 |
75 |
76 | );
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/PowerSelectMultiple/SelectTrigger.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import AutoResizeInput from '../AutoResizeInput';
3 | import SelectedOption from './SelectedOption';
4 | import TriggerWrapper from '../TriggerWrapper';
5 |
6 | export default class SelectTrigger extends Component {
7 | state = {
8 | value: '',
9 | };
10 |
11 | componentWillReceiveProps(nextProps) {
12 | let value = nextProps.searchTerm !== null ? nextProps.searchTerm : '';
13 | this.setState({
14 | value,
15 | });
16 | }
17 |
18 | handleClearClick = event => {
19 | this.props.onClearClick(event, { select: this.props.select });
20 | };
21 |
22 | render() {
23 | let {
24 | selectedOption,
25 | optionLabelPath,
26 | showOptionClose,
27 | select,
28 | placeholder,
29 | disabled,
30 | autoFocus,
31 | handleOnChange,
32 | handleKeyDown,
33 | handleOnFocus,
34 | handleOnBlur,
35 | selectedOptionComponent,
36 | ...rest
37 | } = this.props;
38 | let selected = selectedOption || [];
39 | return (
40 |
46 |
47 |
48 | {selected.map((selectedOption, index) => {
49 | return (
50 |
59 | );
60 | })}
61 |
62 |
75 |
76 |
77 |
78 |
79 | );
80 | }
81 | }
82 |
83 | SelectTrigger.defaultProps = {
84 | onOptionCloseClick: () => {},
85 | };
86 |
--------------------------------------------------------------------------------
/stories/PowerSelect.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useState } from 'react';
3 | import { storiesOf } from '@storybook/react';
4 | import { withKnobs } from '@storybook/addon-knobs/react';
5 | import { text, array, boolean, object } from '@storybook/addon-knobs/react';
6 | import { action } from '@storybook/addon-actions';
7 | import { countries } from './utils/constants';
8 | import PowerSelect from 'src/PowerSelect';
9 | import Centered from './utils/Centered';
10 |
11 | const powerSelect = storiesOf('Power Select', module);
12 | powerSelect.addDecorator(withKnobs);
13 | powerSelect.addDecorator(Centered);
14 |
15 | const PowerSelectWithHooks = props => {
16 | const [selected, setCountry] = useState(countries[8]);
17 | return (
18 | {
22 | action('onChange')(args);
23 | setCountry(args.option);
24 | }}
25 | optionLabelPath={text('optionLabelPath', 'name')}
26 | placeholder={text('placeholder', 'Select your favorite country')}
27 | disabled={boolean('disabled', false)}
28 | showClear={boolean('showClear', true)}
29 | searchEnabled={boolean('searchEnabled', true)}
30 | searchInputAutoFocus={boolean('searchInputAutoFocus', true)}
31 | searchPlaceholder={text('searchPlaceholder', 'Search...')}
32 | searchIndices={array('searchIndices', ['name', 'code'])}
33 | {...props}
34 | />
35 | );
36 | };
37 |
38 | powerSelect.add('with general configs', () => );
39 |
40 | powerSelect.add('customOptionComponent', () => (
41 | {
43 | return (
44 |
45 |
46 |
{option.name}
47 |
{option.code}
48 |
49 | );
50 | }}
51 | />
52 | ));
53 |
54 | powerSelect.add('selectedOptionComponent', () => (
55 | {
57 | return (
58 |
59 |
60 |
{option.name}
61 |
({option.code})
62 |
63 | );
64 | }}
65 | />
66 | ));
67 |
68 | powerSelect.add('beforeOptionsComponent', () => (
69 | Before Option Component
}
71 | />
72 | ));
73 |
74 | powerSelect.add('afterOptionsComponent', () => (
75 | (
77 | {
80 | alert('Lalalala');
81 | select.actions.close();
82 | }}
83 | >
84 | + Add New
85 |
86 | )}
87 | />
88 | ));
89 |
--------------------------------------------------------------------------------
/src/Options.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import cx from 'classnames';
3 | import Option from './Option';
4 | import { getOptionIndex, isOptGroup } from './utils';
5 |
6 | export default class Options extends Component {
7 | componentWillReceiveProps({ options, highlightedOption }) {
8 | this.scrollTo({ options, highlightedOption });
9 | }
10 |
11 | componentDidMount() {
12 | let { options, highlightedOption } = this.props;
13 | this.optionsListOffsetHeight = this.optionsList.offsetHeight;
14 | this.scrollTo({ options, highlightedOption });
15 | }
16 |
17 | componentDidUpdate() {
18 | if (!this.optionsListOffsetHeight) {
19 | this.optionsListOffsetHeight = this.optionsList.offsetHeight;
20 | }
21 | }
22 |
23 | scrollTo({ options, highlightedOption }) {
24 | if (highlightedOption) {
25 | let optionIndex = getOptionIndex(options, highlightedOption);
26 | let $option = this.optionsList.querySelector(`[data-option-index="${optionIndex}"]`);
27 | let delta = 0;
28 | if ($option) {
29 | let $optionOffsetHeight = $option.offsetHeight;
30 | let $optionOffsetTop = $option.offsetTop;
31 | delta = $optionOffsetTop + $optionOffsetHeight - this.optionsListOffsetHeight;
32 | }
33 | if (delta > 0) {
34 | this.optionsList.scrollTop = delta;
35 | } else {
36 | this.optionsList.scrollTop = 0;
37 | }
38 | }
39 | }
40 |
41 | renderOptions(options, optGroupDisabled = false) {
42 | let { select, optionLabelPath, optionComponent, highlightedOption, onOptionClick } = this.props;
43 | return options.map((option, index) => {
44 | let optionIndex = getOptionIndex(this.props.options, option);
45 | if (isOptGroup(option)) {
46 | return (
47 |
54 |
{option.label}
55 | {this.renderOptions(option.options, option.disabled)}
56 |
57 | );
58 | }
59 | return (
60 | {
70 | onOptionClick(option);
71 | }}
72 | />
73 | );
74 | });
75 | }
76 |
77 | render() {
78 | let { options } = this.props;
79 | return (
80 | (this.optionsList = optionsList)}>
81 | {this.renderOptions(options)}
82 |
83 | );
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/__tests__/utils/matcher-test.js:
--------------------------------------------------------------------------------
1 | // /* global describe, it, expect */
2 | import { matcher } from './../../utils';
3 |
4 | describe('matcher()', () => {
5 | it('should match string', () => {
6 | const option = 'option';
7 | expect(matcher({ option, searchTerm: '' })).toBe(true);
8 | expect(matcher({ option, searchTerm: 'opt' })).toBe(true);
9 | expect(matcher({ option, searchTerm: 'opt ' })).toBe(true);
10 | expect(matcher({ option, searchTerm: 'a' })).toBe(false);
11 | });
12 | it('should match number', () => {
13 | const option = 1234567;
14 | expect(matcher({ option, searchTerm: '' })).toBe(true);
15 | expect(matcher({ option, searchTerm: '12' })).toBe(true);
16 | expect(matcher({ option, searchTerm: '12 ' })).toBe(true);
17 | expect(matcher({ option, searchTerm: '9' })).toBe(false);
18 | });
19 | it('should match not undefined', () => {
20 | const option = undefined;
21 | expect(matcher({ option, searchTerm: '' })).toBe(false);
22 | expect(matcher({ option, searchTerm: 'opt' })).toBe(false);
23 | expect(matcher({ option, searchTerm: '12' })).toBe(false);
24 | });
25 | it('should match not null', () => {
26 | const option = null;
27 | expect(matcher({ option, searchTerm: '' })).toBe(false);
28 | expect(matcher({ option, searchTerm: 'opt' })).toBe(false);
29 | expect(matcher({ option, searchTerm: '12' })).toBe(false);
30 | });
31 |
32 | const option = { name: 'Matthew Stevens', age: 6, id: null };
33 | it('should match string in object', () => {
34 | expect(matcher({ option, searchTerm: '', searchIndices: 'name' })).toBe(true);
35 | expect(matcher({ option, searchTerm: 'mAt', searchIndices: 'name' })).toBe(true);
36 | expect(matcher({ option, searchTerm: 'mat', searchIndices: 'name' })).toBe(true);
37 | expect(matcher({ option, searchTerm: 'mat ', searchIndices: 'name' })).toBe(true);
38 | expect(matcher({ option, searchTerm: 'b', searchIndices: 'name' })).toBe(false);
39 | });
40 | it('should match number in object', () => {
41 | expect(matcher({ option, searchTerm: '', searchIndices: 'age' })).toBe(true);
42 | expect(matcher({ option, searchTerm: '6', searchIndices: 'age' })).toBe(true);
43 | expect(matcher({ option, searchTerm: '6 ', searchIndices: 'age' })).toBe(true);
44 | expect(matcher({ option, searchTerm: '1', searchIndices: 'age' })).toBe(false);
45 | });
46 | it('should match not undefined in object', () => {
47 | expect(matcher({ option, searchTerm: '', searchIndices: 'gender' })).toBe(false);
48 | expect(matcher({ option, searchTerm: 'opt', searchIndices: 'gender' })).toBe(false);
49 | expect(matcher({ option, searchTerm: '12', searchIndices: 'gender' })).toBe(false);
50 | });
51 | it('should match not null in object', () => {
52 | expect(matcher({ option, searchTerm: '', searchIndices: 'id' })).toBe(false);
53 | expect(matcher({ option, searchTerm: 'opt', searchIndices: 'id' })).toBe(false);
54 | expect(matcher({ option, searchTerm: '12', searchIndices: 'id' })).toBe(false);
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/src/PowerSelectMultiple/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import cx from 'classnames';
3 | import Select from '../Select';
4 | import MultiSelectTrigger from './SelectTrigger';
5 |
6 | export default class PowerSelectMultiple extends Component {
7 | state = {};
8 |
9 | componentWillMount() {
10 | this.filterOptions(this.props.options, this.props.selected);
11 | }
12 |
13 | componentWillReceiveProps(nextProps) {
14 | this.filterOptions(nextProps.options, nextProps.selected);
15 | }
16 |
17 | handleOnChange = ({ option, select }) => {
18 | let { selected, onChange } = this.props;
19 | if (option) {
20 | let options = selected.slice();
21 | options.push(option);
22 | onChange({
23 | options,
24 | select,
25 | });
26 | }
27 | select.actions.focus();
28 | if (select.searchTerm) {
29 | select.actions.search('');
30 | }
31 | };
32 |
33 | filterOptions(options, selected, callback) {
34 | let filteredOptions = options.filter(option => selected.indexOf(option) === -1);
35 | this.setState({ filteredOptions }, callback);
36 | }
37 |
38 | handleKeyDown = (event, { select }) => {
39 | if (event.which === 8) {
40 | let { selected, onChange } = this.props;
41 | let value = event.target.value;
42 | if (value === '' && selected.length) {
43 | let options = selected.slice(0, selected.length - 1);
44 | onChange({
45 | options,
46 | select,
47 | });
48 | select.actions.open();
49 | select.actions.focus();
50 | }
51 | }
52 | if (this.props.onKeyDown) {
53 | this.props.onKeyDown(event, { select });
54 | }
55 | };
56 |
57 | removeOption = ({ option, select }) => {
58 | let { selected, onChange } = this.props;
59 | let options = selected.filter(opt => opt !== option);
60 | onChange({
61 | options,
62 | select,
63 | });
64 | select.actions.focus();
65 | };
66 |
67 | handleClearClick = (event, { select }) => {
68 | event.stopPropagation();
69 | this.props.onChange({
70 | options: [],
71 | select,
72 | });
73 | if (select.searchTerm) {
74 | select.actions.search('');
75 | }
76 | select.actions.close();
77 | select.actions.focus();
78 | };
79 |
80 | render() {
81 | let { className, options, onChange, ...rest } = this.props;
82 | return (
83 | (this.select = select)}
86 | triggerComponent={props => (
87 |
93 | )}
94 | {...rest}
95 | options={this.state.filteredOptions}
96 | onChange={this.handleOnChange}
97 | closeOnSelect={false}
98 | onKeyDown={this.handleKeyDown}
99 | />
100 | );
101 | }
102 | }
103 |
104 | PowerSelectMultiple.displayName = 'PowerSelectMultiple';
105 |
--------------------------------------------------------------------------------
/src/__tests__/test-utils/create-page-object.js:
--------------------------------------------------------------------------------
1 | import $ from 'jquery';
2 | import React from 'react';
3 | import sinon from 'sinon';
4 | import { ReactWrapper, shallow, mount } from 'enzyme';
5 | import { frameworks, countries } from './constants';
6 | import TestUtils from 'react-dom/test-utils';
7 |
8 | export default class PageObjectBase {
9 | handleChange = sinon.spy();
10 |
11 | renderWithProps(props) {
12 | let selected;
13 | let attachToContainer;
14 | let Component = this.Component;
15 |
16 | if (!this.attachToContainer) {
17 | attachToContainer = this.attachToContainer = document.createElement('div');
18 | document.body.appendChild(attachToContainer);
19 | }
20 |
21 | let mountedComponent = (this.mountedComponent = mount(
22 | ,
29 | {
30 | attachTo: attachToContainer,
31 | }
32 | ));
33 | return mountedComponent;
34 | }
35 |
36 | unmount() {
37 | if (this.mountedComponent) {
38 | this.mountedComponent.detach();
39 | }
40 | }
41 |
42 | get portal() {
43 | return $('.PowerSelect__Tether');
44 | }
45 |
46 | get isOpened() {
47 | let wrapper = this.mountedComponent;
48 | let hasOpenClass = this.container.hasClass('PowerSelect--open');
49 | let isDropdownVisible = this.portal.exists();
50 | return isDropdownVisible && hasOpenClass;
51 | }
52 |
53 | get container() {
54 | return this.mountedComponent.find('.PowerSelect');
55 | }
56 |
57 | get trigger() {
58 | return this.mountedComponent.find('SelectTrigger');
59 | }
60 |
61 | get isDisabled() {
62 | return this.container.hasClass('PowerSelect--disabled');
63 | }
64 |
65 | triggerContainerClick() {
66 | this.container.simulate('click');
67 | }
68 |
69 | triggerClearClick() {
70 | this.mountedComponent.find('.PowerSelect__Clear').simulate('click');
71 | }
72 |
73 | triggerKeydown(keyCode, count = 1) {
74 | let component = this.container;
75 |
76 | for (let i = 0; i < count; i++) {
77 | component.simulate('keyDown', {
78 | which: keyCode,
79 | keyCode,
80 | });
81 | }
82 | }
83 |
84 | isOptionHighlighted(index) {
85 | return (
86 | this.portal.exists() &&
87 | this.portal
88 | .find('.PowerSelect__Options')
89 | .childAt(index)
90 | .hasClass('PowerSelect__Option--highlighted')
91 | );
92 | }
93 |
94 | clickOption(index) {
95 | this.portal
96 | .find('.PowerSelect__Options')
97 | .childAt(index)
98 | .simulate('click');
99 | }
100 |
101 | enterSearchText(string) {
102 | let input = document.querySelector('input.PowerSelect__SearchInput');
103 | TestUtils.Simulate.change(input, { target: { value: string } });
104 | }
105 |
106 | getVisibleOptions() {
107 | return this.portal.find('.PowerSelect__Options').children();
108 | }
109 |
110 | renderChange() {
111 | let args = this.handleChange.lastCall.args[0];
112 | this.mountedComponent.setProps({
113 | selected: args.option,
114 | });
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/TypeAhead/__tests__/TypeAhead-test.js:
--------------------------------------------------------------------------------
1 | // /* global describe, it, expect */
2 | import React from 'react';
3 | import sinon from 'sinon';
4 | import PageObjectBase from '../../__tests__/test-utils/create-page-object';
5 | import { frameworks, countries, KEY_CODES } from '../../__tests__/test-utils/constants';
6 | import TypeAhead from '../index';
7 |
8 | class TypeAheadPageObject extends PageObjectBase {
9 | Component = TypeAhead;
10 |
11 | get inputComponent() {
12 | return this.mountedComponent.find('.PowerSelect__TriggerInput').hostNodes();
13 | }
14 |
15 | get inputValue() {
16 | return this.inputComponent.props().value;
17 | }
18 | }
19 |
20 | describe(' ', () => {
21 | let powerselect;
22 | beforeEach(() => {
23 | powerselect = new TypeAheadPageObject();
24 | });
25 |
26 | afterEach(() => {
27 | powerselect.unmount();
28 | });
29 |
30 | it('should render the container tag', () => {
31 | const wrapper = powerselect.renderWithProps({
32 | options: frameworks,
33 | });
34 | expect(wrapper.find('.PowerSelect.TypeAhead').length).toBe(1);
35 | expect(wrapper.find('.PowerSelect__Trigger').length).toBe(1);
36 | expect(wrapper.find('.PowerSelect__TriggerInputContainer').length).toBe(1);
37 | expect(wrapper.find('.PowerSelect__TriggerInput').hostNodes().length).toBe(1);
38 | expect(wrapper.find('.PowerSelect__Clear').length).toBe(1);
39 | expect(wrapper.find('.PowerSelect__TriggerStatus').length).toBe(1);
40 | });
41 |
42 | it('should preselect, when `selected` is passed', () => {
43 | let selectedOption = frameworks[2];
44 | const wrapper = powerselect.renderWithProps({
45 | options: frameworks,
46 | selected: selectedOption,
47 | });
48 | expect(powerselect.inputValue).toBe(selectedOption);
49 | });
50 |
51 | it('should preselect, when `selected` is passed even with object option', () => {
52 | let selectedOption = countries[2];
53 | const wrapper = powerselect.renderWithProps({
54 | selected: selectedOption,
55 | });
56 | expect(powerselect.inputValue).toBe(selectedOption.name);
57 | });
58 |
59 | it('should clear the selected option, when the clear button is clicked', () => {
60 | let selectedOption = countries[2];
61 | const wrapper = powerselect.renderWithProps({
62 | selected: selectedOption,
63 | });
64 | expect(powerselect.inputValue).toBe(selectedOption.name);
65 | powerselect.triggerClearClick();
66 | expect(powerselect.handleChange.calledOnce).toBeTruthy();
67 | powerselect.renderChange();
68 | expect(powerselect.inputValue).toBeFalsy();
69 | });
70 |
71 | it('should display placeholder when passed', () => {
72 | const placeholder = 'Please select a country';
73 | const wrapper = powerselect.renderWithProps({ placeholder });
74 | expect(powerselect.inputComponent.props().placeholder).toBe(placeholder);
75 | });
76 |
77 | it('should be disabled, when `disabled` prop is set', () => {
78 | const wrapper = powerselect.renderWithProps({
79 | disabled: true,
80 | });
81 | expect(powerselect.isDisabled).toBeTruthy();
82 | powerselect.triggerContainerClick();
83 | expect(powerselect.isOpened).toBeFalsy();
84 | });
85 |
86 | it('should pass highlightedOption to triggerComponent', () => {
87 | const wrapper = powerselect.renderWithProps({
88 | selected: countries[0],
89 | });
90 | powerselect.triggerContainerClick();
91 | powerselect.triggerKeydown(KEY_CODES.DOWN_ARROW);
92 | expect(powerselect.isOptionHighlighted(1)).toBeTruthy();
93 | expect(powerselect.trigger.props().highlightedOption).toBe(countries[1]);
94 | powerselect.triggerKeydown(KEY_CODES.DOWN_ARROW);
95 | expect(powerselect.isOptionHighlighted(2)).toBeTruthy();
96 | expect(powerselect.trigger.props().highlightedOption).toBe(countries[2]);
97 | });
98 | });
99 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-power-select",
3 | "version": "1.0.0-beta.19",
4 | "description": "A highly composable & reusable select component in react",
5 | "main": "lib/index.js",
6 | "scripts": {
7 | "test": "jest",
8 | "clean": "rm -rf lib",
9 | "build:commonjs": "babel src/ --out-dir lib/",
10 | "build": "npm run clean && npm run build:commonjs && webpack && rm -rf dist/css.js",
11 | "prepublish": "npm run build",
12 | "precommit": "lint-staged",
13 | "coveralls": "cat ./coverage/lcov.info | ./node_modules/.bin/coveralls",
14 | "storybook": "start-storybook -p 6006",
15 | "deploy-storybook": "storybook-to-ghpages"
16 | },
17 | "jest": {
18 | "automock": false,
19 | "verbose": true,
20 | "collectCoverage": true,
21 | "coverageReporters": [
22 | "html",
23 | "text",
24 | "lcov"
25 | ],
26 | "roots": [
27 | "src"
28 | ],
29 | "setupTestFrameworkScriptFile": "/jestEnvironment.js",
30 | "setupFiles": [
31 | "./testSetup.js"
32 | ],
33 | "unmockedModulePathPatterns": [
34 | "node_modules",
35 | "babel"
36 | ],
37 | "testPathIgnorePatterns": [
38 | "src/__tests__/test-utils"
39 | ]
40 | },
41 | "lint-staged": {
42 | "src/**/*.js": [
43 | "prettier --print-width 100 --single-quote --trailing-comma es5 --write",
44 | "git add"
45 | ],
46 | "stories/**/*.js": [
47 | "prettier --print-width 100 --single-quote --trailing-comma es5 --write",
48 | "git add"
49 | ],
50 | "docs/app/Demos/**/*.js": [
51 | "prettier --print-width 100 --single-quote --trailing-comma es5 --write",
52 | "git add"
53 | ]
54 | },
55 | "repository": {
56 | "type": "git",
57 | "url": "git+https://github.com/selvagsz/react-power-select.git"
58 | },
59 | "author": "selvagsz (selvaganeshbsg@gmail.com)",
60 | "license": "MIT",
61 | "bugs": {
62 | "url": "https://github.com/selvagsz/react-power-select/issues"
63 | },
64 | "keywords": [
65 | "react-power-select",
66 | "react-autocomplete",
67 | "react-select",
68 | "react-typeahead",
69 | "react-multiselect",
70 | "react-component"
71 | ],
72 | "files": [
73 | "lib",
74 | "dist"
75 | ],
76 | "homepage": "https://github.com/selvagsz/react-power-select#readme",
77 | "devDependencies": {
78 | "@storybook/addon-actions": "^3.4.0",
79 | "@storybook/addon-knobs": "^3.4.0",
80 | "@storybook/addon-links": "^3.4.0",
81 | "@storybook/addon-options": "^3.4.0",
82 | "@storybook/addons": "^3.4.0",
83 | "@storybook/react": "^3.4.0",
84 | "@storybook/storybook-deployer": "^2.8.11",
85 | "babel-cli": "^6.24.1",
86 | "babel-core": "^6.24.1",
87 | "babel-jest": "^20.0.3",
88 | "babel-loader": "^7.0.0",
89 | "babel-polyfill": "^6.26.0",
90 | "babel-preset-es2015": "^6.24.1",
91 | "babel-preset-react": "^6.24.1",
92 | "babel-preset-stage-0": "^6.24.1",
93 | "codemirror": "^5.27.2",
94 | "coveralls": "^3.0.0",
95 | "css-loader": "^0.28.2",
96 | "enzyme": "^3.3.0",
97 | "enzyme-adapter-react-16": "^1.1.1",
98 | "extract-text-webpack-plugin": "^2.1.0",
99 | "html-webpack-plugin": "^2.28.0",
100 | "husky": "^0.13.4",
101 | "jest": "^20.0.4",
102 | "jquery": "^3.3.1",
103 | "jsdom": "^11.6.2",
104 | "lint-staged": "^3.6.1",
105 | "minimist": "^1.2.0",
106 | "multi-loader": "^0.1.0",
107 | "node-sass": "7.0.1",
108 | "prettier": "^1.6.1",
109 | "raf": "^3.4.0",
110 | "react": "16.7.0-alpha.2",
111 | "react-codemirror2": "^4.0.0",
112 | "react-dom": "16.7.0-alpha.2",
113 | "react-test-renderer": "16.7.0-alpha.2",
114 | "regenerator-runtime": "^0.10.5",
115 | "sass-loader": "^6.0.5",
116 | "sinon": "^3.2.1",
117 | "style-loader": "^0.18.1",
118 | "webpack": "^2.6.0"
119 | },
120 | "dependencies": {
121 | "classnames": "^2.2.5",
122 | "prop-types": "^15.5.10",
123 | "react-tether": "^0.6.1"
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | import React, { isValidElement, cloneElement } from 'react';
2 |
3 | export const matcher = ({ option, searchTerm = '', searchIndices }) => {
4 | searchTerm = searchTerm.trim().toLowerCase();
5 |
6 | if (searchIndices) {
7 | return makeArray(searchIndices).some(index => {
8 | let value = option[index];
9 | return (
10 | !isNone(value) &&
11 | String(value)
12 | .toLowerCase()
13 | .indexOf(searchTerm) !== -1
14 | );
15 | });
16 | }
17 |
18 | return (
19 | !isNone(option) &&
20 | String(option)
21 | .toLowerCase()
22 | .indexOf(searchTerm) !== -1
23 | );
24 |
25 | return true;
26 | };
27 |
28 | export const isNone = value => value === null || value === undefined;
29 |
30 | export const makeArray = obj => {
31 | if (obj === null || obj === undefined) {
32 | return [];
33 | }
34 | return Array.isArray(obj) ? obj : [obj];
35 | };
36 |
37 | export const isOptGroup = option => option.label && option.options;
38 |
39 | export const getOptionIndex = (options, option) => {
40 | let paths = [];
41 | let optionFound = false;
42 | (function traverse(options) {
43 | optionFound = options.some((currentOption, index) => {
44 | if (currentOption === option) {
45 | paths.push(index);
46 | optionFound = true;
47 | return true;
48 | }
49 | if (isOptGroup(currentOption)) {
50 | paths.push(index);
51 | return traverse(currentOption.options, index);
52 | }
53 | });
54 | if (!optionFound) {
55 | paths.pop();
56 | }
57 | return optionFound;
58 | })(options);
59 | return paths.join('.');
60 | };
61 |
62 | export const flattenOptions = options => {
63 | let isOptGroupOptions = false;
64 | let optGroupMap = new Map();
65 | let flattenedOptions = (function traverse(options, flattenedOptions = [], group = {}) {
66 | return options.reduce((prev, currentOption) => {
67 | if (isOptGroup(currentOption)) {
68 | isOptGroupOptions = true;
69 | return traverse(currentOption.options, prev, currentOption);
70 | }
71 | prev.push(currentOption);
72 | optGroupMap.set(currentOption, group);
73 | return prev;
74 | }, flattenedOptions);
75 | })(options);
76 | return {
77 | isOptGroupOptions,
78 | flattenedOptions,
79 | optGroupMap,
80 | };
81 | };
82 |
83 | export const filterOptions = ({ options, searchTerm, searchIndices, matcher }) => {
84 | return (function doFilter(options) {
85 | let filtered = [];
86 | for (let i = 0, len = options.length; i < len; i++) {
87 | let option = options[i];
88 | if (isOptGroup(option)) {
89 | let copy = { ...option };
90 | copy.options = doFilter(option.options);
91 | if (copy.options.length) {
92 | filtered.push(copy);
93 | }
94 | } else if (matcher({ option, searchTerm, searchIndices })) {
95 | filtered.push(option);
96 | }
97 | }
98 | return filtered;
99 | })(options);
100 | };
101 |
102 | export const getNextValidOption = ({ options, currentOption, counter, optGroupMap }) => {
103 | return (function next(currentOption) {
104 | let currentIndex = options.indexOf(currentOption);
105 | let nextIndex = currentIndex + counter;
106 | nextIndex =
107 | nextIndex === -1 ? options.length - 1 : nextIndex === options.length ? 0 : nextIndex;
108 | let nextOption = options[nextIndex];
109 | let group = optGroupMap.get(nextOption);
110 | if (nextOption && (nextOption.disabled || group.disabled)) {
111 | return next(nextOption);
112 | }
113 | return nextOption;
114 | })(currentOption);
115 | };
116 |
117 | export const isValidOptionPresent = options => {
118 | return (function traverse(options) {
119 | return !!options.some(option => {
120 | if (isOptGroup(option)) {
121 | return traverse(option.options);
122 | }
123 | return !option.disabled;
124 | });
125 | })(options);
126 | };
127 |
128 | export const renderComponent = (Component, props) => {
129 | if (isValidElement(Component)) {
130 | return cloneElement(Component, props);
131 | }
132 | if (Component) {
133 | return ;
134 | }
135 | };
136 |
--------------------------------------------------------------------------------
/src/styles/PowerSelect.scss:
--------------------------------------------------------------------------------
1 | .PowerSelect {
2 | cursor: pointer;
3 | border: 1px solid #ccc;
4 | border-radius: 4px;
5 | background-color: #fff;
6 |
7 | &:focus {
8 | outline: none;
9 | }
10 | }
11 |
12 | .PowerSelect--focused {
13 | border-color: #66afe9;
14 | }
15 |
16 | .PowerSelect--disabled {
17 | background-color: #eee;
18 | cursor: not-allowed;
19 |
20 | .PowerSelect__Trigger {
21 | pointer-events: none;
22 | }
23 |
24 | .PowerSelect__TriggerInput {
25 | background-color: #eee;
26 | }
27 | }
28 |
29 | .PowerSelect--open {
30 | //Tether specific style changes to suport auto reposition of drop down menu
31 | &.tether-target-attached-top {
32 | border-top-right-radius: 0;
33 | border-top-left-radius: 0;
34 | border-top: 0;
35 | }
36 | &.tether-target-attached-bottom {
37 | border-bottom-right-radius: 0;
38 | border-bottom-left-radius: 0;
39 | border-bottom: 0;
40 | }
41 |
42 | .PowerSelect__TriggerStatus::before {
43 | transform: rotate(-180deg);
44 | }
45 | }
46 |
47 | .PowerSelect__Trigger {
48 | position: relative;
49 | height: 34px;
50 | overflow: hidden;
51 | white-space: nowrap;
52 | }
53 |
54 | .PowerSelect__Menu {
55 | background-color: #fff;
56 | border: 1px solid #ccc;
57 |
58 | &:focus {
59 | outline: none;
60 | }
61 | }
62 |
63 | .PowerSelect__Options {
64 | position: relative;
65 | max-height: 238px;
66 | overflow: auto;
67 | }
68 |
69 | .PowerSelect__OptGroup {
70 | padding-left: 8px;
71 | }
72 |
73 | .PowerSelect__OptGroup__Label {
74 | font-weight: bold;
75 | font-size: 0.9em;
76 | color: #666;
77 | padding: 8px 0 4px;
78 | }
79 |
80 | .PowerSelect__Option {
81 | cursor: pointer;
82 | padding: 8px 12px;
83 |
84 | &:not(.PowerSelect__Option--disabled):hover {
85 | background-color: #fbfbfb;
86 | }
87 | }
88 |
89 | .PowerSelect__Option--disabled {
90 | color: #999;
91 | cursor: not-allowed;
92 | }
93 |
94 | .PowerSelect__Option--highlighted {
95 | background-color: #f1f1f1;
96 | }
97 |
98 | .PowerSelect__BeforeOptions {
99 | padding: 8px 12px;
100 | }
101 |
102 | .PowerSelect__Placeholder {
103 | color: #ccc;
104 | font-size: 14px;
105 | }
106 |
107 | .PowerSelect__SearchInputContainer {
108 | padding: 8px;
109 | }
110 |
111 | .PowerSelect__SearchInput {
112 | display: block;
113 | width: 100%;
114 | height: 34px;
115 | padding: 4px 8px;
116 | line-height: 1.4;
117 | font-size: inherit;
118 | border: 1px solid #ccc;
119 | border-radius: 2px;
120 | cursor: pointer;
121 |
122 | &:focus {
123 | border-color: #66afe9;
124 | outline: none;
125 | }
126 | }
127 |
128 | .PowerSelect__TriggerInput {
129 | display: inline-block;
130 | width: 100%;
131 | height: 34px;
132 | border: none;
133 | cursor: pointer;
134 | padding: 4px 8px 4px 8px;
135 | font-size: inherit;
136 | border-top-left-radius: 4px;
137 | border-bottom-left-radius: 4px;
138 |
139 | &:focus {
140 | outline: none;
141 | }
142 | }
143 |
144 | .PowerSelect__Trigger__LHS,
145 | .PowerSelect__Trigger__RHS {
146 | display: table-cell;
147 | vertical-align: middle;
148 | white-space: nowrap;
149 | }
150 |
151 | .PowerSelect__TriggerLabel {
152 | padding: 6px 8px;
153 | overflow: hidden;
154 | max-width: 0px;
155 | text-overflow: ellipsis;
156 | }
157 |
158 | .PowerSelect__Trigger__LHS + .PowerSelect__TriggerLabel {
159 | padding-left: 4px;
160 | }
161 |
162 | .PowerSelect__TriggerStatus {
163 | padding-top: 2px;
164 | padding-right: 8px;
165 | padding-left: 4px;
166 |
167 | &::before {
168 | content: '';
169 | display: block;
170 | border-top: 4px solid #ccc;
171 | border-left: 4px solid transparent;
172 | border-right: 4px solid transparent;
173 | }
174 | }
175 |
176 | .PowerSelect__Clear {
177 | padding: 4px;
178 | color: #888;
179 | &:hover {
180 | color: #333;
181 | }
182 | &::before {
183 | content: '×'
184 | }
185 | }
186 |
187 | .PowerSelect__Trigger--empty {
188 | .PowerSelect__Clear {
189 | visibility: hidden;
190 | }
191 | }
192 |
193 | .PowerSelect__TriggerLabel,
194 | .PowerSelect__TriggerInputContainer,
195 | .PowerSelectMultiple__OptionsContainer,
196 | .PowerSelect__Clear,
197 | .PowerSelect__TriggerStatus {
198 | display: table-cell;
199 | width: 100%;
200 | vertical-align: middle;
201 | height: 34px;
202 | }
203 |
204 | // MultiSelect
205 | .PowerSelectMultiple__OptionsContainer {
206 | padding-top: 2px;
207 | padding-bottom: 2px;
208 | }
209 |
210 | .PowerSelectMultiple__SelectedOptions {
211 | list-style: none;
212 | padding-left: 0;
213 | display: block;
214 |
215 | &::after {
216 | content: '';
217 | display: table;
218 | clear: both;
219 | }
220 |
221 | > li {
222 | display: block;
223 | float: left;
224 | margin-top: 2px;
225 | margin-bottom: 2px;
226 | }
227 | }
228 |
229 | .PowerSelect__Trigger--empty .PowerSelectMultiple_TriggerInputContainer {
230 | float: none;
231 | margin-left: 4px;
232 | }
233 |
234 | .PowerSelectMultiple__SelectedOption {
235 | line-height: 24px;
236 | margin-left: 4px;
237 | background-color: #ebeeff;
238 | border: 1px solid #c6cfff;
239 | }
240 |
241 | .PowerSelectMultiple__SelectedOption__Label,
242 | .PowerSelectMultiple__SelectedOption__Close {
243 | padding: 0 6px;
244 | display: block;
245 | float: left;
246 | }
247 |
248 | .PowerSelectMultiple__SelectedOption__Close {
249 | border-left: 1px solid #c6cfff;
250 | cursor: pointer;
251 | &:hover {
252 | background-color: #dce1ff;
253 | }
254 | }
255 |
256 | .PowerSelectMultiple {
257 | .PowerSelect__Trigger {
258 | white-space: normal;
259 | height: auto;
260 | }
261 |
262 | .PowerSelect__TriggerInput {
263 | display: inline-block;
264 | width: auto;
265 | padding: 0;
266 | height: 26px;
267 | margin-left: 4px;
268 | }
269 |
270 | .PowerSelect__Trigger--empty .PowerSelect__TriggerInput {
271 | width: 100%;
272 | }
273 | }
274 |
--------------------------------------------------------------------------------
/src/PowerSelectMultiple/__tests__/PowerSelectMultiple-test.js:
--------------------------------------------------------------------------------
1 | // /* global describe, it, expect */
2 | import React from 'react';
3 | import sinon from 'sinon';
4 | import PageObjectBase from '../../__tests__/test-utils/create-page-object';
5 | import { frameworks, countries, KEY_CODES } from '../../__tests__/test-utils/constants';
6 | import PowerSelectMultiple from '../index';
7 |
8 | class PowerSelectMultiplePageObject extends PageObjectBase {
9 | Component = PowerSelectMultiple;
10 |
11 | renderWithProps(props = {}) {
12 | if (!props.selected) {
13 | props.selected = [];
14 | }
15 | return super.renderWithProps(props);
16 | }
17 |
18 | get renderedSelectedOptions() {
19 | return this.mountedComponent.find('.PowerSelectMultiple__SelectedOption');
20 | }
21 |
22 | get triggerInput() {
23 | return this.mountedComponent.find('.PowerSelect__TriggerInput').hostNodes();
24 | }
25 |
26 | getSelectedOptionAt(index) {
27 | return this.renderedSelectedOptions.at(index);
28 | }
29 |
30 | getSelectedOptionLabelAt(index) {
31 | return this.getSelectedOptionAt(index)
32 | .find('.PowerSelectMultiple__SelectedOption__Label')
33 | .text();
34 | }
35 |
36 | getSelectedOptionCloseAt(index) {
37 | return this.renderedSelectedOptions
38 | .at(index)
39 | .find('.PowerSelectMultiple__SelectedOption__Close');
40 | }
41 |
42 | triggerOptionClearAt(index) {
43 | return this.getSelectedOptionCloseAt(index).simulate('click');
44 | }
45 |
46 | triggerSelectedOptionClickAt(index) {
47 | return this.getSelectedOptionAt(index).simulate('click');
48 | }
49 |
50 | renderChange() {
51 | let args = this.handleChange.lastCall.args[0];
52 | this.mountedComponent.setProps({
53 | selected: args.options,
54 | });
55 | }
56 |
57 | isOptionsPresentInDropdown(options) {
58 | let selectedOptions = options || this.handleChange.lastCall.args[0].options;
59 | let dropdownOptions = this.mountedComponent.instance().select.dropdownRef.props.options;
60 | return selectedOptions.some(option => dropdownOptions.includes(option));
61 | }
62 | }
63 |
64 | describe(' ', () => {
65 | let powerselect;
66 | beforeEach(() => {
67 | powerselect = new PowerSelectMultiplePageObject();
68 | });
69 |
70 | afterEach(() => {
71 | powerselect.unmount();
72 | });
73 |
74 | it('should render the container tag', () => {
75 | const wrapper = powerselect.renderWithProps({
76 | options: frameworks,
77 | });
78 | expect(wrapper.find('.PowerSelect.PowerSelectMultiple').length).toBe(1);
79 | expect(wrapper.find('.PowerSelect__Trigger').length).toBe(1);
80 | expect(wrapper.find('.PowerSelectMultiple__OptionsContainer').length).toBe(1);
81 | expect(wrapper.find('.PowerSelect__TriggerInput').hostNodes().length).toBe(1);
82 | expect(wrapper.find('.PowerSelect__Clear').length).toBe(1);
83 | expect(wrapper.find('.PowerSelect__TriggerStatus').length).toBe(1);
84 | });
85 |
86 | it('should preselect options, when `selected` is passed', () => {
87 | let selectedOptions = [frameworks[2], frameworks[3]];
88 | const wrapper = powerselect.renderWithProps({
89 | options: frameworks,
90 | selected: selectedOptions,
91 | });
92 | expect(powerselect.renderedSelectedOptions.length).toBe(selectedOptions.length);
93 | expect(powerselect.getSelectedOptionLabelAt(0)).toBe(selectedOptions[0]);
94 | expect(powerselect.getSelectedOptionLabelAt(1)).toBe(selectedOptions[1]);
95 | });
96 |
97 | it('should preselect, when `selected` is passed even with object option', () => {
98 | let selectedOptions = [countries[2], countries[3]];
99 | const wrapper = powerselect.renderWithProps({
100 | selected: selectedOptions,
101 | });
102 | expect(powerselect.renderedSelectedOptions.length).toBe(selectedOptions.length);
103 | expect(powerselect.getSelectedOptionLabelAt(0)).toBe(selectedOptions[0].name);
104 | expect(powerselect.getSelectedOptionCloseAt(0).length).toBe(1);
105 | expect(powerselect.getSelectedOptionLabelAt(1)).toBe(selectedOptions[1].name);
106 | expect(powerselect.getSelectedOptionCloseAt(1).length).toBe(1);
107 | });
108 |
109 | it("should clear the option, when the option's close icon is clicked", () => {
110 | let selectedOptions = [countries[2], countries[3]];
111 | const wrapper = powerselect.renderWithProps({
112 | selected: selectedOptions,
113 | });
114 | expect(powerselect.renderedSelectedOptions.length).toBe(selectedOptions.length);
115 | powerselect.triggerOptionClearAt(0);
116 | expect(powerselect.handleChange.calledOnce).toBeTruthy();
117 | let args = powerselect.handleChange.getCall(0).args[0];
118 | expect(args.options.length).toBe(selectedOptions.length - 1);
119 | expect(args.options).toEqual([selectedOptions[1]]);
120 | expect(args.select).toBeTruthy();
121 | expect(args.select.searchTerm).toBe(null);
122 | wrapper.setProps({
123 | selected: args.options,
124 | });
125 | expect(powerselect.renderedSelectedOptions.length).toBe(selectedOptions.length - 1);
126 | });
127 |
128 | it('should clear the selected option, when the clear button is clicked', () => {
129 | let selectedOptions = [countries[2], countries[3]];
130 | const wrapper = powerselect.renderWithProps({
131 | selected: selectedOptions,
132 | });
133 | expect(powerselect.renderedSelectedOptions.length).toBe(selectedOptions.length);
134 | powerselect.triggerClearClick();
135 | expect(powerselect.handleChange.calledOnce).toBeTruthy();
136 | let args = powerselect.handleChange.getCall(0).args[0];
137 | expect(args.options).toEqual([]);
138 | expect(args.select).toBeTruthy();
139 | expect(args.select.searchTerm).toBe(null);
140 | wrapper.setProps({
141 | selected: args.options,
142 | });
143 | expect(powerselect.renderedSelectedOptions.length).toBe(0);
144 | });
145 |
146 | it('should display placeholder when passed', () => {
147 | const placeholder = 'Please select a country';
148 | const wrapper = powerselect.renderWithProps({ placeholder });
149 | expect(powerselect.triggerInput.props().placeholder).toBeTruthy();
150 | expect(powerselect.triggerInput.props().placeholder).toBe(placeholder);
151 | wrapper.setProps({ selected: [countries[2]] });
152 | expect(powerselect.triggerInput.props().placeholder).toBeFalsy();
153 | });
154 |
155 | it('should be disabled, when `disabled` prop is set', () => {
156 | let selectedOptions = [countries[2], countries[3]];
157 | const wrapper = powerselect.renderWithProps({
158 | selected: selectedOptions,
159 | disabled: true,
160 | });
161 | expect(powerselect.container.hasClass('PowerSelect--disabled')).toBeTruthy();
162 | powerselect.triggerContainerClick();
163 | expect(powerselect.isOpened).toBeFalsy();
164 | powerselect.triggerClearClick();
165 | expect(powerselect.isOpened).toBeFalsy();
166 | powerselect.triggerOptionClearAt(0);
167 | expect(powerselect.isOpened).toBeFalsy();
168 | });
169 |
170 | it('should toggle the dropdown on click', () => {
171 | const wrapper = powerselect.renderWithProps({
172 | selected: [countries[2]],
173 | });
174 | expect(powerselect.isOpened).toBeFalsy();
175 | powerselect.triggerContainerClick();
176 | expect(powerselect.isOpened).toBeTruthy();
177 | powerselect.triggerContainerClick();
178 | expect(powerselect.isOpened).toBeFalsy();
179 | powerselect.triggerSelectedOptionClickAt(0);
180 | expect(powerselect.isOpened).toBeTruthy();
181 | powerselect.triggerSelectedOptionClickAt(0);
182 | expect(powerselect.isOpened).toBeFalsy();
183 | });
184 |
185 | it('should remove the options from the dropdown when selected', () => {
186 | const wrapper = powerselect.renderWithProps();
187 | expect(powerselect.renderedSelectedOptions.length).toBe(0);
188 | powerselect.triggerContainerClick();
189 | powerselect.clickOption(1);
190 | powerselect.renderChange();
191 | expect(powerselect.renderedSelectedOptions.length).toBe(1);
192 | expect(powerselect.isOptionsPresentInDropdown()).toBeFalsy();
193 | powerselect.clickOption(2);
194 | powerselect.renderChange();
195 | expect(powerselect.renderedSelectedOptions.length).toBe(2);
196 | expect(powerselect.isOptionsPresentInDropdown()).toBeFalsy();
197 | powerselect.clickOption(0);
198 | powerselect.renderChange();
199 | expect(powerselect.renderedSelectedOptions.length).toBe(3);
200 | expect(powerselect.isOptionsPresentInDropdown()).toBeFalsy();
201 | });
202 |
203 | it('should add back the options in dropdown when cleared', () => {
204 | let selectedOptions = countries.slice(0, 3);
205 | const wrapper = powerselect.renderWithProps({
206 | selected: selectedOptions,
207 | });
208 | expect(powerselect.renderedSelectedOptions.length).toBe(selectedOptions.length);
209 | powerselect.triggerContainerClick();
210 | expect(powerselect.isOptionsPresentInDropdown(selectedOptions)).toBeFalsy();
211 | powerselect.triggerOptionClearAt(2);
212 | powerselect.renderChange();
213 | expect(powerselect.isOptionsPresentInDropdown([selectedOptions[2]])).toBeTruthy();
214 | powerselect.triggerOptionClearAt(1);
215 | powerselect.renderChange();
216 | expect(powerselect.isOptionsPresentInDropdown([selectedOptions[1]])).toBeTruthy();
217 | powerselect.triggerOptionClearAt(0);
218 | powerselect.renderChange();
219 | expect(powerselect.isOptionsPresentInDropdown([selectedOptions[0]])).toBeTruthy();
220 | expect(powerselect.renderedSelectedOptions.length).toBe(0);
221 | });
222 |
223 | // Bugfix https://github.com/selvagsz/react-power-select/issues/19
224 | it('should add/remove options from dropdown even when dropdown is closed & opened', () => {
225 | const map = {};
226 | document.addEventListener = jest.fn((event, cb) => {
227 | map[event] = cb;
228 | });
229 | const wrapper = powerselect.renderWithProps();
230 | powerselect.triggerContainerClick();
231 | expect(powerselect.isOpened).toBeTruthy();
232 | powerselect.clickOption(1);
233 | powerselect.renderChange();
234 | // Trigger document click. Should re-check this
235 | map.click({
236 | target: {
237 | closest: function(selector) {
238 | return false;
239 | },
240 | },
241 | });
242 | expect(powerselect.isOpened).toBeFalsy();
243 | powerselect.triggerContainerClick();
244 | powerselect.clickOption(1);
245 | powerselect.renderChange();
246 | expect(powerselect.renderedSelectedOptions.length).toBe(2);
247 | expect(powerselect.isOptionsPresentInDropdown()).toBeFalsy();
248 | powerselect.clickOption(1);
249 | powerselect.renderChange();
250 | expect(powerselect.renderedSelectedOptions.length).toBe(3);
251 | expect(powerselect.isOptionsPresentInDropdown()).toBeFalsy();
252 | });
253 |
254 | it('should render custom selected option component when passed', () => {
255 | let selectedOptions = countries.slice(0, 3);
256 | const wrapper = powerselect.renderWithProps({
257 | selected: selectedOptions,
258 | selectedOptionComponent: ({ option }) => {
259 | return {option.name} ;
260 | },
261 | });
262 | expect(powerselect.renderedSelectedOptions.length).toBe(3);
263 | expect(wrapper.find('span.customSelectedOption').length).toBe(3);
264 | expect(
265 | wrapper.containsAllMatchingElements([
266 | {selectedOptions[0].name} ,
267 | {selectedOptions[1].name} ,
268 | {selectedOptions[2].name} ,
269 | ])
270 | ).toBeTruthy();
271 | });
272 | });
273 |
--------------------------------------------------------------------------------
/src/Select.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import cx from 'classnames';
4 | import Dropdown from './Dropdown';
5 | import SelectTrigger from './SelectTrigger';
6 | import DropdownMenu from './DropdownMenu';
7 | import {
8 | matcher,
9 | isOptGroup,
10 | flattenOptions,
11 | filterOptions,
12 | getNextValidOption,
13 | isValidOptionPresent,
14 | } from './utils';
15 |
16 | const KEY_CODES = {
17 | UP_ARROW: 38,
18 | DOWN_ARROW: 40,
19 | ENTER: 13,
20 | TAB: 9,
21 | };
22 |
23 | const actions = {
24 | [KEY_CODES.UP_ARROW]: 'handleUpArrow',
25 | [KEY_CODES.DOWN_ARROW]: 'handleDownArrow',
26 | [KEY_CODES.ENTER]: 'handleEnterPress',
27 | [KEY_CODES.TAB]: 'handleTabPress',
28 | };
29 |
30 | const noop = () => {};
31 |
32 | export default class Select extends Component {
33 | state = {
34 | highlightedOption: null,
35 | isOpen: false,
36 | focused: false,
37 | filteredOptions: null,
38 | searchTerm: null,
39 | };
40 |
41 | documentEventListeners = {
42 | handleEscapePress: ::this.handleEscapePress,
43 | handleDocumentClick: ::this.handleDocumentClick,
44 | };
45 |
46 | componentWillMount() {
47 | this.flattenOptions(this.props.options);
48 | }
49 |
50 | componentWillReceiveProps({ options }) {
51 | this.flattenOptions(options);
52 | if (this.props.options !== options) {
53 | this.setState({
54 | filteredOptions: options,
55 | });
56 | }
57 | }
58 |
59 | componentDidMount() {
60 | document.addEventListener('keydown', this.documentEventListeners.handleEscapePress);
61 | document.addEventListener('click', this.documentEventListeners.handleDocumentClick, true);
62 | }
63 |
64 | componentWillUnmount() {
65 | document.removeEventListener('keydown', this.documentEventListeners.handleEscapePress);
66 | document.removeEventListener('click', this.documentEventListeners.handleDocumentClick, true);
67 | clearTimeout(this.focusFieldTimeout);
68 | }
69 |
70 | flattenOptions(options) {
71 | let { isOptGroupOptions, flattenedOptions, optGroupMap } = flattenOptions(options);
72 | this.isOptGroupOptions = isOptGroupOptions;
73 | this._optGroupMap = optGroupMap;
74 | this.setState({
75 | _flattenedOptions: flattenedOptions,
76 | });
77 | }
78 |
79 | getVisibleOptions() {
80 | return this.state.filteredOptions || this.props.options;
81 | }
82 |
83 | getFlattenedOptions() {
84 | return this.state._flattenedOptions;
85 | }
86 |
87 | setHighlightedOption(highlightedOption) {
88 | this.setState({
89 | highlightedOption,
90 | });
91 | }
92 |
93 | selectOption = option => {
94 | this.props.onChange({
95 | select: this.getPublicApi(),
96 | option,
97 | });
98 | this.setState({
99 | searchTerm: null,
100 | });
101 | };
102 |
103 | open = () => {
104 | if (this.props.disabled) {
105 | return;
106 | }
107 | let flattenedOptions = this.getFlattenedOptions();
108 | if (this.state.highlightedOption === null) {
109 | let { selected } = this.props;
110 | let highlightedOption = flattenedOptions.find(option => option === selected);
111 | this.setHighlightedOption(highlightedOption);
112 | }
113 | this.setState({
114 | isOpen: true,
115 | });
116 | this.props.onOpen({ select: this.getPublicApi() });
117 | };
118 |
119 | close = () => {
120 | this.setState({
121 | isOpen: false,
122 | });
123 | this.props.onClose({ select: this.getPublicApi() });
124 | };
125 |
126 | resetSearchAndClose = () => {
127 | this.search(null);
128 | this.close();
129 | };
130 |
131 | toggle = event => {
132 | if (event && this.powerselect.contains(event.target)) {
133 | event.stopPropagation();
134 | }
135 | if (this.state.isOpen) {
136 | this.resetSearchAndClose();
137 | } else {
138 | this.open();
139 | }
140 | };
141 |
142 | resetSearch = () => {
143 | this.setHighlightedOption(null);
144 | this.setState({
145 | searchTerm: null,
146 | filteredOptions: null,
147 | });
148 | };
149 |
150 | setFocusedState(focused) {
151 | this.setState({ focused });
152 | }
153 |
154 | focusField = () => {
155 | this.focusFieldTimeout = setTimeout(() => {
156 | this.powerselect.focus();
157 | });
158 | };
159 |
160 | search = (searchTerm, callback) => {
161 | let { options, optionLabelPath, matcher, searchIndices = optionLabelPath } = this.props;
162 | let filteredOptions = filterOptions({
163 | options,
164 | searchTerm: searchTerm || '',
165 | searchIndices,
166 | matcher,
167 | });
168 |
169 | let { flattenedOptions } = flattenOptions(filteredOptions || []);
170 | if (searchTerm && flattenedOptions.length) {
171 | this.setHighlightedOption(flattenedOptions[0]);
172 | } else {
173 | this.setHighlightedOption(null);
174 | }
175 |
176 | this.setState(
177 | {
178 | filteredOptions,
179 | searchTerm,
180 | _flattenedOptions: flattenedOptions,
181 | },
182 | callback
183 | );
184 | };
185 |
186 | handleSearchInputChange = event => {
187 | let value = event.target.value;
188 | this.open();
189 | this.search(value);
190 |
191 | if (this.props.onSearchInputChange) {
192 | // show deprecate warning
193 | this.props.onSearchInputChange(event, { select: this.getPublicApi() });
194 | } else {
195 | this.props.onSearch(event, { select: this.getPublicApi() });
196 | }
197 | };
198 |
199 | validateAndHighlightOption(highlightedOption, counter) {
200 | let options = this.getFlattenedOptions();
201 | let isValidOptionAvailable = isValidOptionPresent(options);
202 | if (isValidOptionAvailable) {
203 | let nextValidOption = getNextValidOption({
204 | options,
205 | counter,
206 | currentOption: highlightedOption,
207 | optGroupMap: this._optGroupMap,
208 | });
209 | this.setHighlightedOption(nextValidOption);
210 | }
211 | }
212 |
213 | handleDownArrow(event, highlightedOption) {
214 | event.preventDefault();
215 | this.validateAndHighlightOption(highlightedOption, 1);
216 | }
217 |
218 | handleUpArrow(event, highlightedOption) {
219 | event.preventDefault();
220 | this.validateAndHighlightOption(highlightedOption, -1);
221 | }
222 |
223 | handleEnterPress(event, highlightedOption) {
224 | if (this.state.isOpen) {
225 | this.selectOption(highlightedOption);
226 | this.focusField();
227 | this.resetSearchAndClose();
228 | }
229 | }
230 |
231 | handleTabPress(event, highlightedOption) {
232 | this.setFocusedState(false);
233 | if (this.state.isOpen) {
234 | this.selectOption(highlightedOption);
235 | this.resetSearchAndClose();
236 | }
237 | }
238 |
239 | handleKeyDown = (...args) => {
240 | let [event] = args;
241 | let keyCode = event.which;
242 | let action = this[actions[keyCode]];
243 | if (action) {
244 | if (
245 | (keyCode === KEY_CODES.UP_ARROW || keyCode === KEY_CODES.DOWN_ARROW) &&
246 | !this.state.isOpen
247 | ) {
248 | this.open();
249 | return;
250 | }
251 | action.apply(this, args);
252 | }
253 | this.props.onKeyDown(event, { select: this.getPublicApi() });
254 | };
255 |
256 | handleEscapePress(event) {
257 | if (event.which === 27) {
258 | let $target = event.target;
259 | if (
260 | this.powerselect.contains($target) ||
261 | (this.dropdown && this.dropdown.contains($target))
262 | ) {
263 | this.resetSearchAndClose();
264 | this.focusField();
265 | }
266 | }
267 | }
268 |
269 | handleDocumentClick(event) {
270 | let $target = event.target;
271 | if (
272 | !(
273 | this.powerselect.contains($target) ||
274 | (this.dropdown && this.dropdown.contains(event.target))
275 | )
276 | ) {
277 | let { focused, isOpen } = this.state;
278 | if (focused) {
279 | this.setFocusedState(false);
280 | }
281 | if (isOpen) {
282 | this.resetSearchAndClose();
283 | }
284 | }
285 | }
286 |
287 | handleFocus = event => {
288 | let triggerInput = this.powerselect.querySelector('input');
289 | if (triggerInput) {
290 | triggerInput.focus();
291 | }
292 | this.setFocusedState(true);
293 | if (!this.state.focused) {
294 | this.props.onFocus(event, { select: this.getPublicApi() });
295 | }
296 | };
297 |
298 | handleBlur = event => {
299 | this.setFocusedState(false);
300 | this.props.onBlur(event, { select: this.getPublicApi() });
301 | };
302 |
303 | handleClick = event => {
304 | this.toggle(event);
305 | this.props.onClick(event, { select: this.getPublicApi() });
306 | };
307 |
308 | handleClearClick = event => {
309 | this.selectOption(undefined);
310 | this.resetSearchAndClose();
311 | this.focusField();
312 | event.stopPropagation();
313 | };
314 |
315 | handleOptionClick = highlightedOption => {
316 | this.selectOption(highlightedOption);
317 | this.focusField();
318 | if (this.props.closeOnSelect) {
319 | this.resetSearchAndClose();
320 | }
321 | };
322 |
323 | getPublicApi() {
324 | let { isOpen, searchTerm } = this.state;
325 | return {
326 | isOpen,
327 | searchTerm,
328 | actions: {
329 | open: this.open,
330 | close: this.close,
331 | search: this.search,
332 | focus: this.focusField,
333 | },
334 | };
335 | }
336 |
337 | render() {
338 | let {
339 | className,
340 | tabIndex,
341 | selected,
342 | showClear,
343 | optionLabelPath,
344 | optionComponent,
345 | placeholder,
346 | disabled,
347 | horizontalPosition,
348 | selectedOptionComponent,
349 | selectedOptionLabelPath,
350 | triggerLHSComponent,
351 | triggerRHSComponent,
352 | beforeOptionsComponent,
353 | afterOptionsComponent,
354 | } = this.props;
355 |
356 | let { isOpen, searchTerm, highlightedOption, focused } = this.state;
357 | let Trigger = this.props.triggerComponent;
358 | let options = this.getVisibleOptions();
359 | let selectApi = this.getPublicApi();
360 |
361 | return (
362 |
363 | {
365 | this.powerselect = powerselect;
366 | }}
367 | className={cx('PowerSelect', className, {
368 | 'PowerSelect--disabled': disabled,
369 | 'PowerSelect--open': isOpen,
370 | 'PowerSelect--focused': focused,
371 | PowerSelect__WithSearch: searchTerm,
372 | })}
373 | tabIndex={tabIndex}
374 | onClick={this.handleClick}
375 | onFocus={this.handleFocus}
376 | onKeyDown={event => {
377 | this.handleKeyDown(event, highlightedOption);
378 | }}
379 | >
380 |
397 |
398 | {isOpen && (
399 | (this.dropdownRef = dropdownRef)}
401 | onRef={dropdown => (this.dropdown = dropdown)}
402 | className={className}
403 | minWidth={this.powerselect.offsetWidth}
404 | options={options}
405 | selected={selected}
406 | optionLabelPath={optionLabelPath}
407 | optionComponent={optionComponent}
408 | onOptionClick={this.handleOptionClick}
409 | handleKeyDown={this.handleKeyDown}
410 | highlightedOption={highlightedOption}
411 | select={selectApi}
412 | beforeOptionsComponent={beforeOptionsComponent}
413 | afterOptionsComponent={afterOptionsComponent}
414 | />
415 | )}
416 |
417 | );
418 | }
419 | }
420 |
421 | Select.propTypes = {
422 | options: PropTypes.array.isRequired,
423 | selected: PropTypes.oneOfType([PropTypes.string, PropTypes.object, PropTypes.array]),
424 | onChange: PropTypes.func.isRequired,
425 | };
426 |
427 | Select.defaultProps = {
428 | options: [],
429 | disabled: false,
430 | tabIndex: 0,
431 | showClear: true,
432 | closeOnSelect: true,
433 | optionLabelPath: null,
434 | optionComponent: null,
435 | triggerComponent: SelectTrigger,
436 | triggerLHSComponent: null,
437 | triggerRHSComponent: null,
438 | selectedOptionComponent: null,
439 | beforeOptionsComponent: null,
440 | afterOptionsComponent: null,
441 | horizontalPosition: 'left',
442 | matcher: matcher,
443 | onFocus: noop,
444 | onBlur: noop,
445 | onClick: noop,
446 | onKeyDown: noop,
447 | onOpen: noop,
448 | onClose: noop,
449 | onSearch: noop,
450 | };
451 |
--------------------------------------------------------------------------------
/src/PowerSelect/__tests__/PowerSelect-test.js:
--------------------------------------------------------------------------------
1 | // /* global describe, it, expect */
2 | import React from 'react';
3 | import sinon from 'sinon';
4 | import PageObjectBase from '../../__tests__/test-utils/create-page-object';
5 | import { frameworks, countries, KEY_CODES } from '../../__tests__/test-utils/constants';
6 | import PowerSelect from '../index';
7 |
8 | class PowerSelectPageObject extends PageObjectBase {
9 | Component = PowerSelect;
10 | }
11 |
12 | describe(' ', () => {
13 | let powerselect;
14 | beforeEach(() => {
15 | powerselect = new PowerSelectPageObject();
16 | });
17 |
18 | afterEach(() => {
19 | powerselect.unmount();
20 | });
21 |
22 | it('should render the container tag', () => {
23 | const wrapper = powerselect.renderWithProps({
24 | options: frameworks,
25 | });
26 | expect(wrapper.find('.PowerSelect').length).toBe(1);
27 | expect(wrapper.find('.PowerSelect__Trigger').length).toBe(1);
28 | expect(wrapper.find('.PowerSelect__TriggerLabel').length).toBe(1);
29 | expect(wrapper.find('.PowerSelect__Clear').length).toBe(1);
30 | expect(wrapper.find('.PowerSelect__TriggerStatus').length).toBe(1);
31 | });
32 |
33 | it('should preselect, when `selected` is passed', () => {
34 | let selectedOption = frameworks[2];
35 | const wrapper = powerselect.renderWithProps({
36 | options: frameworks,
37 | selected: selectedOption,
38 | });
39 | expect(wrapper.find('.PowerSelect__TriggerLabel').text()).toBe(selectedOption);
40 | });
41 |
42 | it('should preselect, when `selected` is passed even with object option', () => {
43 | let selectedOption = countries[2];
44 | const wrapper = powerselect.renderWithProps({
45 | selected: selectedOption,
46 | });
47 | expect(wrapper.find('.PowerSelect__TriggerLabel').text()).toBe(selectedOption.name);
48 | });
49 |
50 | it('should not render close button, when `showClear` is false', () => {
51 | const wrapper = powerselect.renderWithProps({
52 | showClear: false,
53 | });
54 | expect(wrapper.find('.PowerSelect__Clear').length).toBe(0);
55 | });
56 |
57 | it('should clear the selected option, when the clear button is clicked', () => {
58 | const wrapper = powerselect.renderWithProps({
59 | selected: countries[2],
60 | });
61 |
62 | expect(wrapper.find('.PowerSelect__TriggerLabel').text()).toBe('Canada');
63 | wrapper.find('.PowerSelect__Clear').simulate('click');
64 | expect(powerselect.handleChange.calledOnce).toBeTruthy();
65 |
66 | let args = powerselect.handleChange.getCall(0).args[0];
67 | expect(args.option).toBe(undefined);
68 | expect(args.select).toBeTruthy();
69 | expect(args.select.searchTerm).toBe(null);
70 |
71 | wrapper.setProps({
72 | selected: args.option,
73 | });
74 | expect(wrapper.find('.PowerSelect__TriggerLabel').text()).toBeFalsy();
75 | });
76 |
77 | it('should delegate `className` to the container, tether & menu', () => {
78 | const wrapper = powerselect.renderWithProps({
79 | className: 'TestPowerSelect',
80 | });
81 | expect(wrapper.find('.PowerSelect').hasClass('TestPowerSelect')).toBeTruthy();
82 |
83 | powerselect.triggerContainerClick();
84 | expect(
85 | powerselect.portal.find('.PowerSelect__Menu').hasClass('TestPowerSelect__Menu')
86 | ).toBeTruthy();
87 | expect(document.querySelectorAll('.PowerSelect__Tether.TestPowerSelect__Tether').length).toBe(
88 | 1
89 | );
90 | });
91 |
92 | it('should delegate `tabIndex` when passed', () => {
93 | const tabIndex = 2;
94 | const wrapper = powerselect.renderWithProps({ tabIndex });
95 | expect(wrapper.find('.PowerSelect').prop('tabIndex')).toBe(tabIndex);
96 | });
97 |
98 | it('should display placeholder when passed', () => {
99 | const placeholder = 'Please select a country';
100 | const wrapper = powerselect.renderWithProps({ placeholder });
101 | expect(wrapper.find('.PowerSelect__Placeholder').exists()).toBeTruthy();
102 | expect(wrapper.find('.PowerSelect__Placeholder').text()).toBe(placeholder);
103 |
104 | wrapper.setProps({ selected: countries[2] });
105 | expect(wrapper.find('.PowerSelect__Placeholder').exists()).toBeFalsy();
106 | });
107 |
108 | it('should display searchPlaceholder when passed', () => {
109 | const searchPlaceholder = 'Search...';
110 | const wrapper = powerselect.renderWithProps({ searchPlaceholder });
111 | powerselect.triggerContainerClick();
112 | expect(
113 | powerselect.portal
114 | .find(`.PowerSelect__SearchInput[placeholder="${searchPlaceholder}"]`)
115 | .exists()
116 | ).toBeTruthy();
117 | });
118 |
119 | it('should render `triggerLHSComponent` when passed', () => {
120 | const SearchIcon = ;
121 | const TriggerLHSComponent = () => SearchIcon;
122 | const wrapper = powerselect.renderWithProps({
123 | triggerLHSComponent: TriggerLHSComponent,
124 | });
125 |
126 | expect(wrapper.contains(SearchIcon)).toBeTruthy();
127 | expect(wrapper.find('.PowerSelect__Trigger__LHS').exists()).toBeTruthy();
128 | });
129 |
130 | it('should render `triggerRHSComponent` when passed', () => {
131 | const SearchIcon = ;
132 | const TriggerRHSComponent = () => SearchIcon;
133 | const wrapper = powerselect.renderWithProps({
134 | triggerRHSComponent: TriggerRHSComponent,
135 | });
136 |
137 | expect(wrapper.contains(SearchIcon)).toBeTruthy();
138 | expect(wrapper.find('.PowerSelect__Trigger__RHS').exists()).toBeTruthy();
139 | });
140 |
141 | it('should be disabled, when `disabled` prop is set', () => {
142 | const wrapper = powerselect.renderWithProps({
143 | disabled: true,
144 | });
145 |
146 | expect(wrapper.find('.PowerSelect').hasClass('PowerSelect--disabled')).toBeTruthy();
147 | powerselect.triggerContainerClick();
148 | expect(powerselect.isOpened).toBeFalsy();
149 | });
150 |
151 | it('should disable search when `searchEnabled` is false', () => {
152 | const wrapper = powerselect.renderWithProps();
153 | powerselect.triggerContainerClick();
154 | expect(powerselect.portal.find('.PowerSelect__SearchInput').exists()).toBeTruthy();
155 |
156 | wrapper.setProps({
157 | searchEnabled: false,
158 | });
159 |
160 | expect(powerselect.portal.find('.PowerSelect__SearchInput').exists()).toBeFalsy();
161 | });
162 |
163 | it('should toggle the dropdown on click', () => {
164 | const wrapper = powerselect.renderWithProps({
165 | selected: countries[2],
166 | });
167 |
168 | expect(powerselect.isOpened).toBeFalsy();
169 | powerselect.triggerContainerClick();
170 | expect(powerselect.isOpened).toBeTruthy();
171 | powerselect.triggerContainerClick();
172 | expect(powerselect.isOpened).toBeFalsy();
173 | });
174 |
175 | it('should render the options in the dropdown');
176 |
177 | it('should close the dropdown on document click', () => {
178 | const map = {};
179 | document.addEventListener = jest.fn((event, cb) => {
180 | map[event] = cb;
181 | });
182 |
183 | const wrapper = powerselect.renderWithProps();
184 |
185 | powerselect.triggerContainerClick();
186 | expect(powerselect.isOpened).toBeTruthy();
187 |
188 | // Should re-check this
189 | map.click({
190 | target: {
191 | closest: function(selector) {
192 | return false;
193 | },
194 | },
195 | });
196 | expect(powerselect.isOpened).toBeFalsy();
197 | });
198 |
199 | it('should highlight the above/below option on up/down arrow press', () => {
200 | const wrapper = powerselect.renderWithProps();
201 | powerselect.triggerContainerClick();
202 |
203 | expect(powerselect.isOptionHighlighted(0)).toBeFalsy();
204 | powerselect.triggerKeydown(KEY_CODES.DOWN_ARROW);
205 | expect(powerselect.isOptionHighlighted(0)).toBeTruthy();
206 |
207 | powerselect.triggerKeydown(KEY_CODES.DOWN_ARROW, 3);
208 | expect(powerselect.isOptionHighlighted(0)).toBeFalsy();
209 | expect(powerselect.isOptionHighlighted(3)).toBeTruthy();
210 |
211 | powerselect.triggerKeydown(KEY_CODES.UP_ARROW);
212 | expect(powerselect.isOptionHighlighted(3)).toBeFalsy();
213 | expect(powerselect.isOptionHighlighted(2)).toBeTruthy();
214 |
215 | powerselect.triggerKeydown(KEY_CODES.UP_ARROW, 2);
216 | expect(powerselect.isOptionHighlighted(0)).toBeTruthy();
217 | });
218 |
219 | it('should highlight the first/last option on circular', () => {
220 | let optionsCount = countries.length;
221 | const wrapper = powerselect.renderWithProps();
222 | powerselect.triggerContainerClick();
223 | powerselect.triggerKeydown(KEY_CODES.DOWN_ARROW, optionsCount);
224 |
225 | expect(powerselect.isOptionHighlighted(optionsCount - 1)).toBeTruthy();
226 | powerselect.triggerKeydown(KEY_CODES.DOWN_ARROW);
227 | expect(powerselect.isOptionHighlighted(0)).toBeTruthy();
228 | powerselect.triggerKeydown(KEY_CODES.UP_ARROW);
229 | expect(powerselect.isOptionHighlighted(optionsCount - 1)).toBeTruthy();
230 | });
231 |
232 | it('should highlight the selected option when opened', () => {
233 | let selectionIndex = 2;
234 | const wrapper = powerselect.renderWithProps({
235 | selected: countries[selectionIndex],
236 | });
237 | powerselect.triggerContainerClick();
238 | expect(powerselect.isOptionHighlighted(selectionIndex)).toBeTruthy();
239 | });
240 |
241 | // it('should make the highlighted option within viewport');
242 |
243 | it('should select the option when click', () => {
244 | let optionIndex = 2;
245 | let optionToBeSelected = countries[2];
246 | const wrapper = powerselect.renderWithProps();
247 | powerselect.triggerContainerClick();
248 |
249 | powerselect.clickOption(optionIndex);
250 | let args = powerselect.handleChange.getCall(0).args[0];
251 | expect(powerselect.handleChange.calledOnce).toBeTruthy();
252 |
253 | expect(args.option).toBe(optionToBeSelected);
254 | expect(args.select).toBeTruthy();
255 |
256 | wrapper.setProps({
257 | selected: args.option,
258 | });
259 | expect(wrapper.find('.PowerSelect__TriggerLabel').text()).toBe(optionToBeSelected.name);
260 | });
261 |
262 | it('should select the option on tab & focusout', () => {
263 | const wrapper = powerselect.renderWithProps();
264 | powerselect.triggerContainerClick();
265 |
266 | powerselect.triggerKeydown(KEY_CODES.DOWN_ARROW, 2);
267 | powerselect.triggerKeydown(KEY_CODES.TAB);
268 |
269 | let args = powerselect.handleChange.getCall(0).args[0];
270 | expect(powerselect.handleChange.calledOnce).toBeTruthy();
271 |
272 | expect(args.option).toBe(countries[1]);
273 | expect(args.select).toBeTruthy();
274 |
275 | wrapper.setProps({
276 | selected: args.option,
277 | });
278 | expect(wrapper.find('.PowerSelect__TriggerLabel').text()).toBe(countries[1].name);
279 | });
280 |
281 | it('should select the option on enter & focusin', () => {
282 | const wrapper = powerselect.renderWithProps();
283 | powerselect.triggerContainerClick();
284 |
285 | powerselect.triggerKeydown(KEY_CODES.DOWN_ARROW, 2);
286 | powerselect.triggerKeydown(KEY_CODES.ENTER);
287 |
288 | let args = powerselect.handleChange.getCall(0).args[0];
289 | expect(powerselect.handleChange.calledOnce).toBeTruthy();
290 |
291 | expect(args.option).toBe(countries[1]);
292 | expect(args.select).toBeTruthy();
293 |
294 | wrapper.setProps({
295 | selected: args.option,
296 | });
297 | expect(wrapper.find('.PowerSelect__TriggerLabel').text()).toBe(countries[1].name);
298 | });
299 |
300 | it('should close dropdown on escape press', () => {
301 | const map = {};
302 | document.addEventListener = jest.fn((event, cb) => {
303 | map[event] = cb;
304 | });
305 |
306 | const wrapper = powerselect.renderWithProps();
307 |
308 | powerselect.triggerContainerClick();
309 | expect(powerselect.isOpened).toBeTruthy();
310 |
311 | //mock contains
312 | powerselect.mountedComponent.instance().select.powerselect.contains = jest
313 | .fn()
314 | .mockReturnValue(true);
315 |
316 | // Should re-check this
317 | map.keydown({
318 | which: KEY_CODES.ESCAPE,
319 | keyCode: KEY_CODES.ESCAPE,
320 | });
321 |
322 | expect(powerselect.isOpened).toBeFalsy();
323 | });
324 |
325 | it('should filter the options based on the searchTerm', () => {
326 | const wrapper = powerselect.renderWithProps();
327 | powerselect.triggerContainerClick();
328 | let optionsCount = powerselect.getVisibleOptions().length;
329 | powerselect.enterSearchText('in');
330 | let filteredOptions = powerselect.getVisibleOptions();
331 | let filteredOptionsCount = filteredOptions.length;
332 |
333 | expect(filteredOptionsCount).toBeLessThan(optionsCount);
334 |
335 | for (let i = 0; i < filteredOptionsCount; i++) {
336 | expect(filteredOptions.at(i).text()).toMatch(/in/gi);
337 | }
338 |
339 | powerselect.enterSearchText('');
340 | expect(powerselect.getVisibleOptions().length).toBeGreaterThan(filteredOptionsCount);
341 | expect(powerselect.getVisibleOptions().length).toBe(optionsCount);
342 | });
343 |
344 | it('should use custom `matcher` func when provided', () => {
345 | const matcher = ({ option, searchTerm = '', searchIndices }) => {
346 | return option[searchIndices].toLowerCase().indexOf(searchTerm) !== -1;
347 | };
348 |
349 | const wrapper = powerselect.renderWithProps({
350 | matcher,
351 | });
352 |
353 | powerselect.triggerContainerClick();
354 | let optionsCount = powerselect.getVisibleOptions().length;
355 | powerselect.enterSearchText('abc');
356 | expect(powerselect.getVisibleOptions().length).toBe(0);
357 | powerselect.enterSearchText('');
358 | expect(powerselect.getVisibleOptions().length).toBe(optionsCount);
359 | powerselect.enterSearchText('india');
360 | expect(powerselect.getVisibleOptions().length).toBe(1);
361 | powerselect.enterSearchText('');
362 | expect(powerselect.getVisibleOptions().length).toBe(optionsCount);
363 | });
364 |
365 | it('should reset the filter when the dropdown is closed', () => {
366 | const wrapper = powerselect.renderWithProps();
367 | powerselect.triggerContainerClick();
368 | let optionsCount = powerselect.getVisibleOptions().length;
369 | powerselect.enterSearchText('in');
370 | expect(powerselect.getVisibleOptions().length).toBeLessThan(optionsCount);
371 |
372 | powerselect.triggerContainerClick(); // close it
373 | powerselect.triggerContainerClick(); // open again
374 | expect(powerselect.getVisibleOptions().length).toBe(optionsCount);
375 | });
376 |
377 | it('should trigger `onFocus` when the powerselect is focused', () => {
378 | const handleOnFocus = sinon.spy();
379 | const wrapper = powerselect.renderWithProps({
380 | onFocus: handleOnFocus,
381 | });
382 |
383 | expect(handleOnFocus.calledOnce).toBeFalsy();
384 | wrapper.find('.PowerSelect').simulate('focus');
385 | expect(handleOnFocus.calledOnce).toBeTruthy();
386 |
387 | let args = handleOnFocus.getCall(0).args;
388 | expect(args.length).toBe(2);
389 | expect(args[0].type).toBe('focus');
390 | expect(args[1].select).toBeTruthy();
391 | });
392 |
393 | it('should trigger `onBlur` when the powerselect is blurred');
394 |
395 | it('should trigger `onOpen` when the powerselect is opened');
396 |
397 | it('should trigger `onClose` when the powerselect is closed');
398 |
399 | it('should trigger `onKeyDown` when any key is pressed');
400 |
401 | it('should trigger `onSearchInputChange` when searchInput changes');
402 |
403 | it('should render the `optionComponent`, when provided');
404 |
405 | it('should render the `selectedOptionComponent`, when provided');
406 |
407 | it('should render the `beforeOptionsComponent`, when provided');
408 |
409 | it('should render the `afterOptionsComponent`, when provided');
410 |
411 | it('should disable & prevent clicks & highlights when the option is disabled');
412 |
413 | it('should prevent max call limit recursion, when all options are disabled');
414 | });
415 |
--------------------------------------------------------------------------------