├── .babelrc ├── .gitignore ├── README.md ├── assets └── sample.gif ├── build └── index.js ├── demo ├── demo.js └── index.html ├── package.json ├── src ├── components │ ├── MenuItem.jsx │ └── MenuSection.jsx ├── containers │ └── QuickSelectMenu.jsx └── react-qsm.css ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react"], 3 | "plugins": ["transform-class-properties", "transform-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public/bundle.js 3 | .vscode -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Quick-Select Menu 2 | 3 | Light-weight quick-select menu with fuzzy search. Inspired by the [vs-code command palette.](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette) 4 | 5 | ![react-qsm demo](./assets/sample.gif) 6 | 7 | ## Table of Contents 8 | 9 | - [Usage](#usage) 10 | - [Installation](#installation) 11 | - [Props](#props) 12 | - [menuSection Properties](#menusection-properties) 13 | - [Styling](#styling) 14 | - [Future Plans](#future-plans) 15 | 16 | ## Usage 17 | 18 | The code below creates the demo you see above. 19 | 20 | ```javascript 21 | import React, { Component } from 'react'; 22 | import ReactDOM from 'react-dom'; 23 | import QuickSelectMenu from 'react-qsm'; 24 | import './react-qsm.css'; 25 | 26 | const sections = [ 27 | { 28 | label: 'recently opened', 29 | items: [{ label: 'demo.js' }, { label: 'index.html' }] 30 | }, 31 | { 32 | prefix: '>', 33 | label: 'recently used', 34 | items: [{ label: 'Preferences: Open User Settings' }, { label: 'Sync: Upload Settings' }] 35 | }, 36 | { 37 | prefix: '>', 38 | label: 'other commands', 39 | items: [{ label: 'Add Cursor Above' }, { label: 'Add Cursor Below' }] 40 | }, 41 | { 42 | prefix: '?', 43 | label: 'help', 44 | items: [{ label: '... Go to file' }, { label: '# Go to symbol in workspace' }] 45 | } 46 | ]; 47 | 48 | const onMenuItemSelect = item => console.log(item); 49 | 50 | ReactDOM.render( 51 | , 52 | document.getElementById('root') 53 | ); 54 | ``` 55 | 56 | ## Installation 57 | 58 | `yarn add react-qsm` or `npm install --save react-qsm` 59 | 60 | ## Props 61 | 62 | | Prop | Type | Required | default | Description | 63 | | :---------------- | :------------------- | :-------------------------: | :------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 64 | | menuSections | _array[menuSection]_ | ✓ | | Array of menuSections. These contain all of the data for the menuItems as well. | 65 | | onMenuItemSelect | _function_ | | | Callback to fire when a menu item is selected. A menuItem will be passed into this callback as the only argument. | 66 | | onMenuItemFocus | _function_ | | | Callback to fire when a menu item is focused. A menuItem will be passed into this callback as the only argument. | 67 | | defaultValue | _string_ | | '' | Initial text value of the input. If provided, this would likely be a section prefix. | 68 | | maxItemsToDisplay | _number_ | | Infinity | Maximum number of items to display in the quick select menu at once . | 69 | | renderInput | _function_ | | | Custom input to render. If this prop is specified, you MUST also make use of the `value` prop(see below) and pass along the given props to the callback function of `renderInput` for react-qsm to function properly. IE: `renderInput={props => }` | 70 | | value | _string_ | if renderInput is specified | | The current value of the custom input supplied to renderInput. This should only be used when rendering a custom input. | 71 | 72 | | className | _string_ | | 'react-qsm' | Class name for the menu wrapper (div) | 73 | | inputClassName | _string_ | | 'qsm-input' | Class name for the menu input (input) | 74 | | menuSectionWrapperClassName | _string_ | | 'qsm-menu-sections-wrapper' | Class name for the menu sections wrapper (div) | 75 | | menuSectionClassName | _string_ | | 'qsm-menu-section' | Class name for a menu section (div) | 76 | | menuSectionLabelClassName | _string_ | | 'qsm-menu-section-label' | Class name for a menu label (h2) | 77 | | menuItemClassName | _string_ | | 'qsm-menu-item | Class name for a menu item (li) | 78 | 79 | ### menuSection Properties 80 | 81 | | Prop | Type | Required | Description | 82 | | :----- | :-------------- | :------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 83 | | items | _array[object]_ | ✓ | Array of item objects, which will be passed to `onMenuItemSelect` when selected. The only required property in these objects is `label`, but you can put whatever you want in here (ie `id`). | 84 | | label | _string_ | | A label to display for your section. | 85 | | prefix | _string_ | | A prefix to match at the beginning of the input field in order to display this section. If a prefix for a section is provided, the input box **must** match the prefix to display this section. If a prefix is provided for _any_ section, sections without a prefix will not match when the input box matches the provided prefix. | 86 | 87 | ## Styling 88 | 89 | - There is a minimal and clean stylesheet to get you on your feet quickly located at react-qsm/src/react-qsm.css. 90 | - If you have questions on ways to import a stylesheet, consult the documentation of your build system. 91 | 92 | ## Future Plans 93 | 94 | - Ability to add components to both the left and right side of a menu item. This would allow things like icons to be displayed next to a label. 95 | -------------------------------------------------------------------------------- /assets/sample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amorijs/react-qsm/77e68189ebae7e3f6b86ba9e9cbe9b5c327d5f15/assets/sample.gif -------------------------------------------------------------------------------- /demo/demo.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import QuickSelectMenu from '../src/containers/QuickSelectMenu.jsx'; 4 | import '../src/react-qsm.css'; 5 | 6 | const sections = [ 7 | { 8 | label: 'recently opened', 9 | items: [{ label: 'demo.js' }, { label: 'index.html' }] 10 | }, 11 | { 12 | prefix: '>', 13 | label: 'recently used', 14 | items: [{ label: 'Preferences: Open User Settings' }, { label: 'Sync: Upload Settings' }] 15 | }, 16 | { 17 | prefix: '>', 18 | label: 'other commands', 19 | items: [{ label: 'Add Cursor Above' }, { label: 'Add Cursor Below' }] 20 | }, 21 | { 22 | prefix: '?', 23 | label: 'help', 24 | items: [{ label: '... Go to file' }, { label: '# Go to symbol in workspace' }] 25 | } 26 | ]; 27 | 28 | const onMenuItemSelect = item => console.log(item); 29 | 30 | ReactDOM.render( 31 | , 37 | document.getElementById('root') 38 | ); 39 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Boilerplate 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-qsm", 3 | "version": "1.2.0", 4 | "description": "Light-weight quick-select menu with fuzzy search. Inspired by the vs-code command palette.", 5 | "main": "build/index.js", 6 | "repository": { 7 | "url": "https://github.com/amorijs/react-qsm" 8 | }, 9 | "author": { 10 | "name": "amorijs", 11 | "email": "chris.amori93@gmail.com" 12 | }, 13 | "license": "MIT", 14 | "scripts": { 15 | "start-dev": "cross-env NODE_ENV=development webpack-dev-server", 16 | "build": "cross-env NODE_ENV=production webpack" 17 | }, 18 | "peerDependencies": { 19 | "react": "^16.4.1" 20 | }, 21 | "dependencies": { 22 | "fuse.js": "^3.2.0", 23 | "prop-types": "^15.6.0" 24 | }, 25 | "devDependencies": { 26 | "babel-cli": "^6.26.0", 27 | "babel-core": "^6.26.0", 28 | "babel-loader": "^7.1.2", 29 | "babel-plugin-transform-class-properties": "^6.24.1", 30 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 31 | "babel-polyfill": "^6.26.0", 32 | "babel-preset-env": "^1.6.1", 33 | "babel-preset-react": "^6.24.1", 34 | "cross-env": "^5.1.3", 35 | "css-loader": "^0.28.9", 36 | "live-server": "^1.2.0", 37 | "node-sass": "^4.7.2", 38 | "react-dom": "^16.2.0", 39 | "sass-loader": "^6.0.6", 40 | "style-loader": "^0.19.1", 41 | "webpack": "^3.10.0", 42 | "webpack-dev-server": "^2.11.0", 43 | "yarn": "^1.3.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/MenuItem.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class MenuItem extends Component { 5 | static propTypes = { label: PropTypes.string.isRequired, className: PropTypes.string }; 6 | 7 | render() { 8 | const { className, label, children, onClick } = this.props; 9 | 10 | return ( 11 |
  • 12 | {label} 13 | {children} 14 |
  • 15 | ); 16 | } 17 | } 18 | 19 | export default MenuItem; 20 | -------------------------------------------------------------------------------- /src/components/MenuSection.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import MenuItem from './MenuItem.jsx'; 4 | 5 | class MenuSection extends Component { 6 | static propTypes = { 7 | name: PropTypes.string, 8 | items: PropTypes.arrayOf(PropTypes.object), 9 | activeIndex: PropTypes.number, 10 | menuSectionClassName: PropTypes.string, 11 | menuSectionLabelClassName: PropTypes.string, 12 | menuItemClassName: PropTypes.string 13 | }; 14 | 15 | render() { 16 | const { 17 | items, 18 | activeIndex, 19 | label, 20 | handleItemClick, 21 | menuSectionClassName, 22 | menuSectionLabelClassName, 23 | menuItemClassName 24 | } = this.props; 25 | 26 | const menuItems = items.map((item, i) => ( 27 | handleItemClick(item)} 29 | key={item.label} 30 | className={menuItemClassName + (i === activeIndex ? ' active' : '')} 31 | label={item.label} 32 | /> 33 | )); 34 | 35 | return ( 36 |
    37 |

    {label}

    38 |
      {menuItems}
    39 |
    40 | ); 41 | } 42 | } 43 | 44 | export default MenuSection; 45 | -------------------------------------------------------------------------------- /src/containers/QuickSelectMenu.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Fuse from 'fuse.js'; 4 | 5 | import MenuSection from '../components/MenuSection.jsx'; 6 | import MenuItem from '../components/MenuItem.jsx'; 7 | 8 | const KEYS = { 9 | UP: 'ArrowUp', 10 | DOWN: 'ArrowDown', 11 | ENTER: 'Enter' 12 | }; 13 | 14 | class QuickSelectMenu extends Component { 15 | static propTypes = { 16 | menuSections: PropTypes.arrayOf(PropTypes.object).isRequired, 17 | onMenuItemSelect: PropTypes.func.isRequired, 18 | onMenItemFocus: PropTypes.func, 19 | defaultValue: PropTypes.string, 20 | value: PropTypes.string, 21 | maxItemsToDisplay: PropTypes.number, 22 | renderInput: PropTypes.func, 23 | className: PropTypes.string, 24 | inputClassName: PropTypes.string, 25 | menuSectionWrapperClassName: PropTypes.string, 26 | menuSectionClassName: PropTypes.string, 27 | menuSectionLabelClassName: PropTypes.string, 28 | menuItemClassName: PropTypes.string 29 | }; 30 | 31 | static defaultProps = { 32 | defaultValue: '', 33 | className: 'react-qsm', 34 | inputClassName: 'qsm-input', 35 | menuSectionWrapperClassName: 'qsm-menu-sections-wrapper', 36 | menuSectionClassName: 'qsm-menu-section', 37 | menuSectionLabelClassName: 'qsm-menu-section-label', 38 | menuItemClassName: 'qsm-menu-item' 39 | }; 40 | 41 | constructor(props) { 42 | super(props); 43 | const { menuSections } = this.props; 44 | 45 | const filteredItemsList = this.createFilteredItemsList(menuSections); 46 | const sectionsByPrefix = this.createSectionsByPrefix(menuSections); 47 | const filteredSections = sectionsByPrefix[''] ? sectionsByPrefix[''] : menuSections; 48 | 49 | this.state = { 50 | filteredSections, 51 | filteredItemsList, 52 | sectionsByPrefix, 53 | activeItemIndex: 0 54 | }; 55 | } 56 | 57 | componentDidMount() { 58 | this.filterSections(this.props.defaultValue); 59 | } 60 | 61 | componentDidUpdate(prevProps, prevState) { 62 | const { value } = this.props; 63 | if (this.props.value !== prevProps.value) this.filterSections(value); 64 | } 65 | 66 | createFilteredItemsList = sections => 67 | sections.reduce((acc, { items }) => { 68 | acc.push(...items); 69 | return acc; 70 | }, []); 71 | 72 | createSectionsByPrefix = sections => 73 | sections.reduce((acc, section) => { 74 | const { prefix = '' } = section; 75 | if (acc[prefix]) acc[prefix].push(section); 76 | else acc[prefix] = [section]; 77 | return acc; 78 | }, {}); 79 | 80 | handleInputChange = event => { 81 | const { value } = event.target; 82 | this.filterSections(value); 83 | }; 84 | 85 | handleKeyDown = event => { 86 | const { key } = event; 87 | const { UP, DOWN, ENTER } = KEYS; 88 | 89 | if (key !== UP && key !== DOWN && key !== ENTER) return; 90 | 91 | event.preventDefault(); 92 | if (key === UP) this.moveUp(event); 93 | if (key === DOWN) this.moveDown(event); 94 | if (key === ENTER) this.selectItemByIndex(this.state.activeItemIndex); 95 | }; 96 | 97 | getItemIndex = item => { 98 | const { filteredItemsList } = this.state; 99 | const indexOfItem = filteredItemsList.indexOf(item); 100 | 101 | if (indexOfItem === -1) { 102 | throw new Error('Cannot set active item. Item does not exist in state.filteredItemsList'); 103 | } 104 | 105 | return indexOfItem; 106 | }; 107 | 108 | setFilteredSections = sections => { 109 | if (!Array.isArray(sections)) { 110 | throw new TypeError(`Invalid argument 'sections'. Must be of type array`); 111 | } 112 | 113 | this.setState({ 114 | filteredSections: sections, 115 | filteredItemsList: this.createFilteredItemsList(sections), 116 | activeItemIndex: 0 117 | }); 118 | }; 119 | 120 | setActiveItem = item => this.setActiveItemByIndex(this.getItemIndex(item)); 121 | selectItem = item => this.selectItemByIndex(this.getItemIndex(item)); 122 | 123 | setActiveItemByIndex = index => { 124 | const { onMenuItemFocus = () => null } = this.props; 125 | 126 | if (index >= this.state.filteredItemsList.length) { 127 | throw new Error(`Cannot set active item of index: ${index}. Index is too large`); 128 | } 129 | 130 | return new Promise(resolve => 131 | this.setState({ activeItemIndex: index }, () => { 132 | onMenuItemFocus(this.state.filteredItemsList[this.state.activeItemIndex]); 133 | resolve(); 134 | }) 135 | ); 136 | }; 137 | 138 | selectItemByIndex = async index => { 139 | const { filteredItemsList, activeItemIndex } = this.state; 140 | const { onMenuItemSelect = () => null } = this.props; 141 | 142 | if (index >= filteredItemsList.length) { 143 | throw new Error(`Cannot set active item of index: ${index}. Index is too large`); 144 | } 145 | 146 | if (index !== activeItemIndex) { 147 | await this.setActiveItemByIndex(index).catch(console.error); 148 | } 149 | 150 | onMenuItemSelect(filteredItemsList[activeItemIndex]); 151 | }; 152 | 153 | filterSections = value => { 154 | if (typeof value !== 'string') { 155 | throw new TypeError(`Invalid argument 'value'. Must be of type string.`); 156 | } 157 | 158 | const { menuSections } = this.props; 159 | const { sectionsByPrefix } = this.state; 160 | 161 | if (value === '') { 162 | const filteredSections = sectionsByPrefix[''] ? sectionsByPrefix[''] : menuSections; 163 | const cappedFilteredSections = this.itemCapSections(filteredSections); 164 | return this.setFilteredSections(cappedFilteredSections); 165 | } 166 | 167 | const prefix = Object.keys(sectionsByPrefix).find( 168 | prefix => prefix !== '' && prefix === value.slice(0, prefix.length) 169 | ); 170 | 171 | const prefixFilteredMenuSections = prefix ? sectionsByPrefix[prefix] : sectionsByPrefix['']; 172 | 173 | const options = { 174 | shouldSort: true, 175 | threshold: 0.4, 176 | keys: ['label'] 177 | }; 178 | 179 | const filteredSections = prefixFilteredMenuSections.reduce((acc, section) => { 180 | const prefixTrimmedValue = prefix ? value.slice(prefix.length) : value; 181 | 182 | let items = section.items; 183 | 184 | if (prefixTrimmedValue.length > 0) { 185 | const fuse = new Fuse(section.items, options); 186 | items = fuse.search(prefixTrimmedValue); 187 | } 188 | 189 | acc.push({ ...section, items }); 190 | return acc; 191 | }, []); 192 | 193 | const cappedSections = this.itemCapSections(filteredSections); 194 | this.setFilteredSections(cappedSections); 195 | }; 196 | 197 | itemCapSections = sections => { 198 | const { maxItemsToDisplay = Infinity } = this.props; 199 | 200 | return sections.reduce( 201 | ({ itemsEncountered, acc }, section) => { 202 | const amountOfItemsThatCanBeAddedToMenu = maxItemsToDisplay - itemsEncountered; 203 | if (amountOfItemsThatCanBeAddedToMenu <= 0) return { acc, itemsEncountered }; 204 | 205 | let items = section.items.slice(0, amountOfItemsThatCanBeAddedToMenu); 206 | 207 | acc.push({ ...section, items }); 208 | return { acc, itemsEncountered: itemsEncountered + items.length }; 209 | }, 210 | { acc: [], itemsEncountered: 0 } 211 | ).acc; 212 | }; 213 | 214 | removeFilters = () => { 215 | const { menuSections } = this.props; 216 | 217 | this.setState({ 218 | filteredSections: menuSections, 219 | filteredItemsList: this.createFilteredItemsList(menuSections) 220 | }); 221 | }; 222 | 223 | moveUp = () => { 224 | const { filteredItemsList, activeItemIndex } = this.state; 225 | const nextIndex = activeItemIndex === 0 ? filteredItemsList.length - 1 : activeItemIndex - 1; 226 | this.setActiveItemByIndex(nextIndex); 227 | }; 228 | 229 | moveDown = () => { 230 | const { filteredItemsList, activeItemIndex } = this.state; 231 | const nextIndex = activeItemIndex === filteredItemsList.length - 1 ? 0 : activeItemIndex + 1; 232 | this.setActiveItemByIndex(nextIndex); 233 | }; 234 | 235 | render() { 236 | const { filteredSections, activeItemIndex } = this.state; 237 | 238 | const { 239 | defaultValue, 240 | renderInput, 241 | className, 242 | inputClassName, 243 | menuSectionWrapperClassName, 244 | menuSectionClassName, 245 | menuSectionLabelClassName, 246 | menuItemClassName 247 | } = this.props; 248 | 249 | let itemsCounted = 0; 250 | let activeSectionIndex = 0; 251 | 252 | for (let i = 0; i < filteredSections.length; i += 1) { 253 | itemsCounted += filteredSections[i].items.length; 254 | if (itemsCounted > activeItemIndex) break; 255 | activeSectionIndex += 1; 256 | } 257 | 258 | const sections = filteredSections.map((section, i) => { 259 | const { label, items } = section; 260 | if (items.length === 0) return; 261 | 262 | const activeIndex = 263 | activeSectionIndex === i ? activeItemIndex - (itemsCounted - items.length) : null; 264 | 265 | return ( 266 | 276 | ); 277 | }); 278 | 279 | const inputProps = { 280 | defaultValue: defaultValue, 281 | onChange: this.handleInputChange, 282 | onKeyDown: this.handleKeyDown, 283 | className: inputClassName 284 | }; 285 | 286 | return ( 287 |
    288 | {renderInput ? renderInput(inputProps) : } 289 |
    {sections}
    290 |
    291 | ); 292 | } 293 | } 294 | 295 | export default QuickSelectMenu; 296 | -------------------------------------------------------------------------------- /src/react-qsm.css: -------------------------------------------------------------------------------- 1 | @import url("http://fonts.googleapis.com/css?family=Droid+Sans"); 2 | 3 | .react-qsm * { 4 | font-family: 'Droid Sans', Arial, Helvetica, sans-serif; 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | .react-qsm { 10 | display: flex; 11 | flex-direction: column; 12 | width: 450px; 13 | background-color: #202032; 14 | border-color: #29273f; 15 | border-radius: 2.5px; 16 | padding: 5px; 17 | } 18 | 19 | .react-qsm .qsm-input { 20 | background-color: #655e5e; 21 | border-color: #d8d8d8; 22 | color: #eaeaea; 23 | border: 1.3px solid #7d7d7d; 24 | border-radius: 1.5px; 25 | margin-bottom: 5px; 26 | padding: 5px; 27 | } 28 | 29 | .react-qsm .qsm-input :focus { 30 | outline: none; 31 | } 32 | 33 | .react-qsm .qsm-menu-section { 34 | position: relative; 35 | display: flex; 36 | flex-direction: column; 37 | padding: 3px 0; 38 | border-bottom: 1px solid #615a5a; 39 | } 40 | 41 | .react-qsm .qsm-menu-section .qsm-menu-section-label { 42 | position: absolute; 43 | right: 0; 44 | padding: 2px 5px; 45 | font-size: 14px; 46 | font-weight: lighter; 47 | color: #d3d3d38f; 48 | opacity: 0.9; 49 | } 50 | 51 | .react-qsm .qsm-menu-section:last-child { 52 | border: none; 53 | } 54 | 55 | .react-qsm .qsm-menu-item { 56 | padding: 5px; 57 | display: flex; 58 | justify-content: flex-start; 59 | background-color: #202032; 60 | color: #ddd; 61 | } 62 | 63 | .react-qsm .qsm-menu-item:hover { 64 | background-color: #3a3838; 65 | } 66 | 67 | .react-qsm .qsm-menu-item:active, .react-qsm .qsm-menu-item.active { 68 | background-color: #655e5e; 69 | color: #eaeaea; 70 | } 71 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | if (process.env.NODE_ENV === 'development') { 4 | module.exports = { 5 | entry: ['babel-polyfill', './demo/demo.js'], 6 | output: { 7 | path: path.join(__dirname, 'demo'), 8 | filename: 'demo-bundle.js' 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.js$|\.jsx$/, 14 | loader: 'babel-loader', 15 | exclude: /(build|node_modules)/ 16 | }, 17 | { 18 | test: /\.css|\.scss/, 19 | use: ['style-loader', 'css-loader', 'sass-loader'] 20 | } 21 | ] 22 | }, 23 | devtool: 'cheap-module-eval-source-map', 24 | devServer: { 25 | contentBase: path.join(__dirname, 'demo') 26 | } 27 | }; 28 | } else { 29 | module.exports = { 30 | entry: ['babel-polyfill', './src/containers/QuickSelectMenu.jsx'], 31 | output: { 32 | path: path.resolve(__dirname, 'build'), 33 | filename: 'index.js', 34 | libraryTarget: 'commonjs2' 35 | }, 36 | module: { 37 | rules: [ 38 | { 39 | test: /\.js|\.jsx/, 40 | include: path.resolve(__dirname, 'src'), 41 | exclude: /(node_modules|build)/, 42 | loader: 'babel-loader' 43 | } 44 | ] 45 | }, 46 | externals: { react: 'commonjs react' } 47 | }; 48 | } 49 | --------------------------------------------------------------------------------