├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .nvmrc ├── .storybook └── main.js ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── cypress.json ├── cypress ├── fixtures │ ├── example.json │ ├── profile.json │ └── users.json ├── integration │ └── MenuList.spec.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── demo ├── index.html └── index.js ├── package-lock.json ├── package.json ├── src ├── MenuList.tsx ├── WindowedSelect.tsx ├── index.ts └── util.ts ├── stories ├── AsyncSelect.stories.js ├── CreatableSelect.stories.js ├── Select.stories.js └── storyUtil.js ├── tests ├── MenuList.spec.js ├── WindowedSelect.spec.js ├── setupTests.js ├── storyshots.spec.js └── util.spec.js ├── tsconfig.json ├── webpack.config.js └── webpack.demo.config.js /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x, 16.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run test:cov 31 | - run: npm run cy:ci 32 | - name: Coveralls GitHub Action 33 | uses: coverallsapp/github-action@1.1.3 34 | with: 35 | github-token: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /demo/node_modules 4 | /dist 5 | /storybook-static 6 | /node_modules 7 | npm-debug.log* 8 | .idea 9 | .npmrc 10 | .cache -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v14.19.1 2 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../stories/**/*.stories.js'], 3 | addons: ['@storybook/addon-actions', '@storybook/addon-links'], 4 | }; 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [5.1.0] - 2022-08-27 9 | ### Updated 10 | - react version to support react 18 11 | 12 | ## [3.1.1] - 2021-01-23 13 | ### Fixed 14 | - dynamic heights when searching 15 | ### Updated 16 | - react-select minor version update 17 | ### Added 18 | - typescript typings 19 | 20 | ## [3.0.0] - 2021-01-23 21 | ### Updated 22 | - react-select major version update 23 | - react-window patch version update 24 | - peer deps to add support for React 17 25 | - dev dep minor/patch versions 26 | 27 | ## [2.0.5] - 2021-01-02 28 | ### Fixed 29 | - invariant violation bug on Windows 10 Firefox 30 | 31 | ## [2.0.3] - 2020-11-02 32 | ### Fixed 33 | - broken npm publish 34 | ### Updated 35 | - dependency patch versions 36 | 37 | ## [2.0.3] - 2020-06-19 38 | ### Fixed 39 | - options with long label text from overflowing outside container 40 | ### Updated 41 | - all components from classes to function components with hooks 42 | 43 | ## [2.0.2] - 2020-01-29 44 | ### Fixed 45 | - not passing proper parameters to noOptionsMessage and loadingMessage 46 | 47 | ## [2.0.1] - 2019-10-18 48 | ### Updated 49 | - all dependencies 50 | 51 | ### Updated 52 | - how padding top and bottom is applied to menu list 53 | 54 | ### Updated 55 | - menu list dom structure to prevent regression regarding scrollbar 56 | 57 | ## [2.0.0] - 2019-07-02 58 | 59 | ### Added 60 | - ref forwarding: refs on the windowed select component will be forwarded to the underlying Select component from react-select 61 | 62 | ### Fixed 63 | - windowThreshold not kicking in for nested grouped options 64 | 65 | ## [1.0.2-beta] - 2019-06-19 66 | 67 | ### Added 68 | - all react-select named exports 69 | 70 | ## [1.0.1-alpha] - 2019-06-17 71 | 72 | ### Added 73 | - support for classNamePrefix prop on windowed menu list 74 | 75 | ## [1.0.0-alpha] - 2019-06-17 76 | 77 | ### Updated 78 | 79 | - react-select from 2.3.0 to 3.0.4: see the [upgrade guide](https://github.com/JedWatson/react-select/issues/3585) for breaking changes from v2 to v3 80 | - react-window to 1.5.0 to 1.8.2 81 | 82 | ### Added 83 | 84 | - react-select components as named export 85 | - windowed MenuList component as named export -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Prerequisites 2 | 3 | [Node.js](http://nodejs.org/) >= 12.13.0 must be installed. 4 | 5 | ## Installation 6 | 7 | - Running `npm install` in the component's root directory will install everything you need for development. 8 | 9 | ## Storybook 10 | 11 | - `npm run storybook` will run Storybook. 12 | 13 | ## Demo Development Server 14 | 15 | - `npm start` will run a development server with the component's demo app at [http://localhost:8080](http://localhost:8080) with hot module reloading. 16 | 17 | ## Running Unit Tests 18 | 19 | - `npm test:cov` will run the unit tests once and produce a coverage report in `coverage/`. 20 | 21 | ## Running End to End Tests 22 | 23 | - `npm run e2e:ci` will run the Cypress end to end tests against Storybook. Alternatively, you can run `npm run storybook` followed by `cypress open` to run the Cypress tests interactively in the Cypress app. 24 | 25 | ## Building 26 | 27 | - `npm run build` will build the component for publishing to npm. 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jacob Worrel 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-windowed-select 2 | 3 |  4 | [![npm package][npm-badge]][npm] 5 | [](https://coveralls.io/github/jacobworrel/react-windowed-select?branch=master) 6 | [![Storybook][storybook-badge]][storybook] 7 | 8 | An integration of `react-window` with `react-select` to efficiently render large lists. 9 | 10 | ## Installation and Usage 11 | 12 | The easiest way to use `react-windowed-select` is to install it from npm: 13 | 14 | ``` 15 | npm install react-windowed-select 16 | ``` 17 | 18 | Then use it in your app: 19 | 20 | ```javascript 21 | import React from "react"; 22 | import WindowedSelect from "react-windowed-select"; 23 | 24 | const options = []; 25 | 26 | for (let i = 0; i < 10000; i += 1) { 27 | options.push({ 28 | label: `Option ${i}`, 29 | value: i 30 | }); 31 | } 32 | 33 | function App () { 34 | return ; 35 | } 36 | ``` 37 | 38 | [](https://codesandbox.io/s/n592j4l13m) 39 | 40 | For more examples, check out the [Storybook][storybook]. 41 | 42 | ## Props 43 | 44 | `react-windowed-select` is just a wrapper around `react-select`. 45 | All props passed to the `WindowedSelect` component are forwarded to the default exported `Select` component 46 | from `react-select`. 47 | 48 | ### windowThreshold | default = 100 49 | 50 | The number of options beyond which the menu will be windowed. 51 | 52 | ## Named Exports 53 | All of the named exports from `react-select` are re-exported from `react-windowed-select` for easy access to features 54 | that allow you to customize your Select component. 55 | 56 | ```javascript 57 | import { components, createFilter } from 'react-windowed-select'; 58 | import React from "react"; 59 | 60 | const options = [ 61 | { value: 1, label: 'Foo' }, 62 | { value: 2, label: 'Bar '}, 63 | ]; 64 | 65 | const customFilter = createFilter({ ignoreAccents: false }); 66 | const customComponents = { 67 | ClearIndicator: (props) => clear 68 | }; 69 | 70 | function App () { 71 | return ( 72 | 78 | ); 79 | } 80 | ``` 81 | [](https://codesandbox.io/s/sweet-snowflake-evjeo?fontsize=14) 82 | 83 | ### WindowedMenuList 84 | By default, `react-windowed-select` wraps the standard Select component from `react-select`. 85 | If you want to add windowing to the Async or Creatable Select components from `react-select`, use the `WindowedMenuList`: 86 | 87 | ```javascript 88 | import { WindowedMenuList } from 'react-windowed-select'; 89 | import CreatableSelect from 'react-select/creatable'; 90 | 91 | function App () { 92 | return ( 93 | 97 | ); 98 | } 99 | ``` 100 | 101 | ## Custom Styles 102 | 103 | You can still use the [styles API](https://www.react-select.com/styles) from `react-select` to customize how your Select component looks. 104 | The height property of the `Option`, `GroupHeading`, `NoOptionsMessage` and/or `LoadingMessage` components is used to determine the total height of the windowed menu and the following defaults are provided: 105 | 106 | |Component |Default Height| 107 | |------------------|--------------| 108 | |`Option` |35px | 109 | |`GroupHeading` |25px | 110 | |`NoOptionsMessage`|35px | 111 | |`LoadingMessage` |35px | 112 | 113 | To override these values, use the `styles` prop like you would with a regular `react-select` component. 114 | 115 | ```javascript 116 | ({ 120 | ...base, 121 | height: 60, // must be type number 122 | padding: '20px 12px', 123 | }), 124 | }} 125 | /> 126 | ``` 127 | 128 | ## Grouped Options 129 | 130 | Grouped options are not fully supported. 131 | In order to ensure proper scrolling and focus behavior, options nested inside the `Group` component are flattened. This changes the component structure within `MenuList` in the following way: 132 | 133 | ``` 134 | MenuList 135 | │ 136 | └───Group 137 | │ │ 138 | | └───GroupHeading 139 | | 140 | └───Option 1 141 | | 142 | └───Option 2 143 | ``` 144 | 145 | [build-badge]: https://img.shields.io/travis/jacobworrel/react-windowed-select/master.png?style=flat-square 146 | [build]: https://travis-ci.org/jacobworrel/react-windowed-select 147 | 148 | [npm-badge]: https://img.shields.io/npm/v/react-windowed-select.png?style=flat-square 149 | [npm]: https://www.npmjs.com/package/react-windowed-select 150 | 151 | [coveralls-badge]: https://img.shields.io/coveralls/jacobworrel/react-windowed-select/master.png?style=flat-square 152 | [coveralls]: https://coveralls.io/github/jacobworrel/react-windowed-select 153 | 154 | [storybook-badge]: https://github.com/storybooks/brand/blob/master/badge/badge-storybook.svg 155 | [storybook]: https://peaceful-leavitt-38971b.netlify.com -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'], 3 | }; -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "video": false, 3 | "chromeWebSecurity": false 4 | } 5 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/fixtures/profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 8739, 3 | "name": "Jane", 4 | "email": "jane@example.com" 5 | } -------------------------------------------------------------------------------- /cypress/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "Leanne Graham", 5 | "username": "Bret", 6 | "email": "Sincere@april.biz", 7 | "address": { 8 | "street": "Kulas Light", 9 | "suite": "Apt. 556", 10 | "city": "Gwenborough", 11 | "zipcode": "92998-3874", 12 | "geo": { 13 | "lat": "-37.3159", 14 | "lng": "81.1496" 15 | } 16 | }, 17 | "phone": "1-770-736-8031 x56442", 18 | "website": "hildegard.org", 19 | "company": { 20 | "name": "Romaguera-Crona", 21 | "catchPhrase": "Multi-layered client-server neural-net", 22 | "bs": "harness real-time e-markets" 23 | } 24 | }, 25 | { 26 | "id": 2, 27 | "name": "Ervin Howell", 28 | "username": "Antonette", 29 | "email": "Shanna@melissa.tv", 30 | "address": { 31 | "street": "Victor Plains", 32 | "suite": "Suite 879", 33 | "city": "Wisokyburgh", 34 | "zipcode": "90566-7771", 35 | "geo": { 36 | "lat": "-43.9509", 37 | "lng": "-34.4618" 38 | } 39 | }, 40 | "phone": "010-692-6593 x09125", 41 | "website": "anastasia.net", 42 | "company": { 43 | "name": "Deckow-Crist", 44 | "catchPhrase": "Proactive didactic contingency", 45 | "bs": "synergize scalable supply-chains" 46 | } 47 | }, 48 | { 49 | "id": 3, 50 | "name": "Clementine Bauch", 51 | "username": "Samantha", 52 | "email": "Nathan@yesenia.net", 53 | "address": { 54 | "street": "Douglas Extension", 55 | "suite": "Suite 847", 56 | "city": "McKenziehaven", 57 | "zipcode": "59590-4157", 58 | "geo": { 59 | "lat": "-68.6102", 60 | "lng": "-47.0653" 61 | } 62 | }, 63 | "phone": "1-463-123-4447", 64 | "website": "ramiro.info", 65 | "company": { 66 | "name": "Romaguera-Jacobson", 67 | "catchPhrase": "Face to face bifurcated interface", 68 | "bs": "e-enable strategic applications" 69 | } 70 | }, 71 | { 72 | "id": 4, 73 | "name": "Patricia Lebsack", 74 | "username": "Karianne", 75 | "email": "Julianne.OConner@kory.org", 76 | "address": { 77 | "street": "Hoeger Mall", 78 | "suite": "Apt. 692", 79 | "city": "South Elvis", 80 | "zipcode": "53919-4257", 81 | "geo": { 82 | "lat": "29.4572", 83 | "lng": "-164.2990" 84 | } 85 | }, 86 | "phone": "493-170-9623 x156", 87 | "website": "kale.biz", 88 | "company": { 89 | "name": "Robel-Corkery", 90 | "catchPhrase": "Multi-tiered zero tolerance productivity", 91 | "bs": "transition cutting-edge web services" 92 | } 93 | }, 94 | { 95 | "id": 5, 96 | "name": "Chelsey Dietrich", 97 | "username": "Kamren", 98 | "email": "Lucio_Hettinger@annie.ca", 99 | "address": { 100 | "street": "Skiles Walks", 101 | "suite": "Suite 351", 102 | "city": "Roscoeview", 103 | "zipcode": "33263", 104 | "geo": { 105 | "lat": "-31.8129", 106 | "lng": "62.5342" 107 | } 108 | }, 109 | "phone": "(254)954-1289", 110 | "website": "demarco.info", 111 | "company": { 112 | "name": "Keebler LLC", 113 | "catchPhrase": "User-centric fault-tolerant solution", 114 | "bs": "revolutionize end-to-end systems" 115 | } 116 | }, 117 | { 118 | "id": 6, 119 | "name": "Mrs. Dennis Schulist", 120 | "username": "Leopoldo_Corkery", 121 | "email": "Karley_Dach@jasper.info", 122 | "address": { 123 | "street": "Norberto Crossing", 124 | "suite": "Apt. 950", 125 | "city": "South Christy", 126 | "zipcode": "23505-1337", 127 | "geo": { 128 | "lat": "-71.4197", 129 | "lng": "71.7478" 130 | } 131 | }, 132 | "phone": "1-477-935-8478 x6430", 133 | "website": "ola.org", 134 | "company": { 135 | "name": "Considine-Lockman", 136 | "catchPhrase": "Synchronised bottom-line interface", 137 | "bs": "e-enable innovative applications" 138 | } 139 | }, 140 | { 141 | "id": 7, 142 | "name": "Kurtis Weissnat", 143 | "username": "Elwyn.Skiles", 144 | "email": "Telly.Hoeger@billy.biz", 145 | "address": { 146 | "street": "Rex Trail", 147 | "suite": "Suite 280", 148 | "city": "Howemouth", 149 | "zipcode": "58804-1099", 150 | "geo": { 151 | "lat": "24.8918", 152 | "lng": "21.8984" 153 | } 154 | }, 155 | "phone": "210.067.6132", 156 | "website": "elvis.io", 157 | "company": { 158 | "name": "Johns Group", 159 | "catchPhrase": "Configurable multimedia task-force", 160 | "bs": "generate enterprise e-tailers" 161 | } 162 | }, 163 | { 164 | "id": 8, 165 | "name": "Nicholas Runolfsdottir V", 166 | "username": "Maxime_Nienow", 167 | "email": "Sherwood@rosamond.me", 168 | "address": { 169 | "street": "Ellsworth Summit", 170 | "suite": "Suite 729", 171 | "city": "Aliyaview", 172 | "zipcode": "45169", 173 | "geo": { 174 | "lat": "-14.3990", 175 | "lng": "-120.7677" 176 | } 177 | }, 178 | "phone": "586.493.6943 x140", 179 | "website": "jacynthe.com", 180 | "company": { 181 | "name": "Abernathy Group", 182 | "catchPhrase": "Implemented secondary concept", 183 | "bs": "e-enable extensible e-tailers" 184 | } 185 | }, 186 | { 187 | "id": 9, 188 | "name": "Glenna Reichert", 189 | "username": "Delphine", 190 | "email": "Chaim_McDermott@dana.io", 191 | "address": { 192 | "street": "Dayna Park", 193 | "suite": "Suite 449", 194 | "city": "Bartholomebury", 195 | "zipcode": "76495-3109", 196 | "geo": { 197 | "lat": "24.6463", 198 | "lng": "-168.8889" 199 | } 200 | }, 201 | "phone": "(775)976-6794 x41206", 202 | "website": "conrad.com", 203 | "company": { 204 | "name": "Yost and Sons", 205 | "catchPhrase": "Switchable contextually-based project", 206 | "bs": "aggregate real-time technologies" 207 | } 208 | }, 209 | { 210 | "id": 10, 211 | "name": "Clementina DuBuque", 212 | "username": "Moriah.Stanton", 213 | "email": "Rey.Padberg@karina.biz", 214 | "address": { 215 | "street": "Kattie Turnpike", 216 | "suite": "Suite 198", 217 | "city": "Lebsackbury", 218 | "zipcode": "31428-2261", 219 | "geo": { 220 | "lat": "-38.2386", 221 | "lng": "57.2232" 222 | } 223 | }, 224 | "phone": "024-648-3804", 225 | "website": "ambrose.net", 226 | "company": { 227 | "name": "Hoeger LLC", 228 | "catchPhrase": "Centralized empowering task-force", 229 | "bs": "target end-to-end models" 230 | } 231 | } 232 | ] -------------------------------------------------------------------------------- /cypress/integration/MenuList.spec.js: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | /** 3 | * @see https://www.cypress.io/blog/2020/02/12/working-with-iframes-in-cypress/ 4 | **/ 5 | const getIframeDocument = () => { 6 | return cy 7 | .get('#storybook-preview-iframe') 8 | // Cypress yields jQuery element, which has the real 9 | // DOM element under property "0". 10 | // From the real DOM iframe element we can get 11 | // the "document" element, it is stored in "contentDocument" property 12 | // Cypress "its" command can access deep properties using dot notation 13 | // https://on.cypress.io/its 14 | .its('0.contentDocument').should('exist'); 15 | }; 16 | 17 | const getIframeBody = () => { 18 | // get the document 19 | return getIframeDocument() 20 | // automatically retries until body is loaded 21 | .its('body').should('not.be.null') 22 | // wraps "body" DOM element to allow 23 | // chaining more Cypress commands, like ".find(...)" 24 | .then(cy.wrap); 25 | }; 26 | 27 | describe('Default Select', () => { 28 | it(`scrolls on arrow down`, () => { 29 | const story = 'default'; 30 | cy.visit(`http://localhost:6006/?path=/story/select--${story}`); 31 | cy.get("#select--default").click(); 32 | const focusedInput = getIframeBody().find(`#${story}`, { timeout: 15000 }) 33 | .find('input') 34 | .first() 35 | .focus(); 36 | 37 | R.times(() => focusedInput.type('{downarrow}', { force: true }), 15); 38 | 39 | getIframeBody().find(`.${story}__menu-list`).contains('Option 15'); 40 | }); 41 | 42 | it(`should have dynamic no options input value`, () => { 43 | const story = 'no-options-msg-with-dynamic-input-value'; 44 | cy.visit(`http://localhost:6006/?path=/story/select--${story}`); 45 | cy.get(`#select--${story}`).click(); 46 | const focusedInput = getIframeBody().find(`#${story}`, { timeout: 15000 }) 47 | .find('input') 48 | .first() 49 | .focus(); 50 | 51 | focusedInput.type('darn', { force: true }); 52 | 53 | getIframeBody().find(`.${story}__menu-list`).contains('No darn options'); 54 | }); 55 | 56 | it(`should have dynamic loading input value`, () => { 57 | const story = 'loading-msg-with-dynamic-input-value'; 58 | cy.visit(`http://localhost:6006/?path=/story/select--${story}`); 59 | cy.get(`#select--${story}`).click(); 60 | const focusedInput = getIframeBody().find(`#${story}`, { timeout: 15000 }) 61 | .find('input') 62 | .first() 63 | .focus(); 64 | 65 | focusedInput.type('cool stuff', { force: true }); 66 | 67 | getIframeBody().find(`.${story}__menu-list`).contains('Loading cool stuff...'); 68 | }); 69 | }); -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | } 22 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import WindowedSelect from '../src'; 4 | 5 | import { 6 | groupedOptions, 7 | options1K, 8 | options5K, 9 | options10K, 10 | options100K, 11 | options1M, 12 | } from './../stories/storyUtil'; 13 | 14 | function Demo () { 15 | return ( 16 | 17 | 1K options 18 | 19 | 20 | 5K options 21 | 22 | 23 | 10K options 24 | 25 | 26 | Grouped 27 | 28 | 29 | ); 30 | } 31 | 32 | render(, document.querySelector('#demo')); 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-windowed-select", 3 | "version": "5.1.1", 4 | "description": "", 5 | "main": "dist/main.js", 6 | "module": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "scripts": { 12 | "start": "webpack-dev-server --mode development --config webpack.demo.config.js", 13 | "test": "jest", 14 | "test:cov": "jest --coverage", 15 | "test:cy": "cypress run", 16 | "cy:ci": "start-server-and-test storybook:ci http://localhost:6006 test:cy", 17 | "storybook": "start-storybook -p 6006", 18 | "storybook:ci": "npm run storybook -- --ci --quiet", 19 | "build-storybook": "build-storybook", 20 | "build": "webpack", 21 | "prepublishOnly": "npm run build", 22 | "check": "npm-check -u -E" 23 | }, 24 | "dependencies": { 25 | "react-select": "^5.2.2", 26 | "react-window": "^1.8.6" 27 | }, 28 | "author": "", 29 | "license": "MIT", 30 | "peerDependencies": { 31 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0", 32 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" 33 | }, 34 | "devDependencies": { 35 | "@types/react": "^18.0.14", 36 | "@types/react-dom": "^18.0.5", 37 | "@types/react-window": "^1.8.2", 38 | "@babel/core": "^7.12.10", 39 | "@babel/preset-env": "^7.12.11", 40 | "@babel/preset-react": "^7.12.10", 41 | "@babel/preset-typescript": "^7.13.0", 42 | "@storybook/addon-actions": "^6.1.21", 43 | "@storybook/addon-links": "^6.1.21", 44 | "@storybook/addon-storyshots": "^6.2.9", 45 | "@storybook/addons": "^6.1.21", 46 | "@storybook/react": "^6.1.21", 47 | "@testing-library/react": "^10.4.9", 48 | "babel-jest": "^26.6.3", 49 | "babel-loader": "^8.2.2", 50 | "concurrently": "^5.3.0", 51 | "cypress": "^4.12.1", 52 | "enzyme": "^3.11.0", 53 | "enzyme-adapter-react-16": "^1.15.6", 54 | "html-webpack-plugin": "^4.5.1", 55 | "jest": "^26.6.3", 56 | "npm-check": "^5.9.2", 57 | "ramda": "^0.27.1", 58 | "react": "^16.14.0", 59 | "react-dom": "^16.14.0", 60 | "react-test-renderer": "^16.14.0", 61 | "start-server-and-test": "^1.11.7", 62 | "ts-loader": "^8.0.18", 63 | "typescript": "^4.2.3", 64 | "webpack": "^4.46.0", 65 | "webpack-cli": "^3.3.12", 66 | "webpack-dev-server": "^3.11.2" 67 | }, 68 | "jest": { 69 | "coveragePathIgnorePatterns": [ 70 | "/node_modules/", 71 | "/tests/", 72 | "/stories/", 73 | "/.storybook/" 74 | ], 75 | "testPathIgnorePatterns": [ 76 | "/node_modules/", 77 | "/cypress/" 78 | ], 79 | "coverageThreshold": { 80 | "global": { 81 | "branches": 78.46, 82 | "functions": 95.83, 83 | "lines": 92.31, 84 | "statements": 92.68 85 | } 86 | }, 87 | "setupFilesAfterEnv": [ 88 | "/tests/setupTests.js" 89 | ] 90 | } 91 | } -------------------------------------------------------------------------------- /src/MenuList.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createGetHeight, 3 | flattenGroupedChildren, 4 | getCurrentIndex 5 | } from './util'; 6 | 7 | import * as React from 'react'; 8 | import { ListChildComponentProps, VariableSizeList as List } from 'react-window'; 9 | import { OptionProps, GroupBase } from 'react-select'; 10 | 11 | interface Style extends React.CSSProperties { 12 | top: number, 13 | } 14 | 15 | interface ListChildProps extends ListChildComponentProps { 16 | style: Style 17 | } 18 | 19 | interface OptionTypeBase { 20 | [key: string]: any; 21 | } 22 | 23 | function MenuList (props) { 24 | const children = React.useMemo( 25 | () => { 26 | const children = React.Children.toArray(props.children); 27 | 28 | const head = children[0] || {}; 29 | 30 | if (React.isValidElement>>(head)) { 31 | const { 32 | props: { 33 | data: { 34 | options = [] 35 | } = {}, 36 | } = {}, 37 | } = head; 38 | const groupedChildrenLength = options.length; 39 | const isGrouped = groupedChildrenLength > 0; 40 | const flattenedChildren = isGrouped && flattenGroupedChildren(children); 41 | 42 | return isGrouped 43 | ? flattenedChildren 44 | : children; 45 | } 46 | else { 47 | return []; 48 | } 49 | }, 50 | [props.children] 51 | ); 52 | 53 | const { getStyles } = props; 54 | const groupHeadingStyles = getStyles('groupHeading', props); 55 | const loadingMsgStyles = getStyles('loadingMessage', props); 56 | const noOptionsMsgStyles = getStyles('noOptionsMessage', props); 57 | const optionStyles = getStyles('option', props); 58 | const getHeight = createGetHeight({ 59 | groupHeadingStyles, 60 | noOptionsMsgStyles, 61 | optionStyles, 62 | loadingMsgStyles, 63 | }); 64 | 65 | const heights = React.useMemo(() => children.map(getHeight), [children]); 66 | const currentIndex = React.useMemo(() => getCurrentIndex(children), [children]); 67 | 68 | const itemCount = children.length; 69 | 70 | const [measuredHeights, setMeasuredHeights] = React.useState({}); 71 | 72 | // calc menu height 73 | const { maxHeight, paddingBottom = 0, paddingTop = 0, ...menuListStyle } = getStyles('menuList', props); 74 | const totalHeight = React.useMemo(() => { 75 | return heights.reduce((sum, height, idx) => { 76 | if (measuredHeights[idx]) { 77 | return sum + measuredHeights[idx]; 78 | } 79 | else { 80 | return sum + height; 81 | } 82 | }, 0); 83 | }, [heights, measuredHeights]); 84 | const totalMenuHeight = totalHeight + paddingBottom + paddingTop; 85 | const menuHeight = Math.min(maxHeight, totalMenuHeight); 86 | const estimatedItemSize = Math.floor(totalHeight / itemCount); 87 | 88 | const { 89 | innerRef, 90 | selectProps, 91 | } = props; 92 | 93 | const { classNamePrefix, isMulti } = selectProps || {}; 94 | const list = React.useRef(null); 95 | 96 | React.useEffect( 97 | () => { 98 | setMeasuredHeights({}); 99 | }, 100 | [props.children] 101 | ); 102 | 103 | // method to pass to inner item to set this items outer height 104 | const setMeasuredHeight = ({ index, measuredHeight }) => { 105 | if (measuredHeights[index] !== undefined && measuredHeights[index] === measuredHeight) { 106 | return; 107 | } 108 | 109 | setMeasuredHeights(measuredHeights => ({ 110 | ...measuredHeights, 111 | [index]: measuredHeight, 112 | })); 113 | 114 | // this forces the list to rerender items after the item positions resizing 115 | if (list.current) { 116 | list.current.resetAfterIndex(index); 117 | } 118 | }; 119 | 120 | React.useEffect( 121 | () => { 122 | /** 123 | * enables scrolling on key down arrow 124 | */ 125 | if (currentIndex >= 0 && list.current !== null) { 126 | list.current.scrollToItem(currentIndex); 127 | } 128 | }, 129 | [currentIndex, children, list] 130 | ); 131 | 132 | return ( 133 | ( 140 | 148 | ))} 149 | height={menuHeight} 150 | width="100%" 151 | itemCount={itemCount} 152 | itemData={children} 153 | itemSize={index => measuredHeights[index] || heights[index]} 154 | > 155 | {({ data, index, style}: ListChildProps) => { 156 | return ( 157 | 162 | 167 | 168 | ) 169 | }} 170 | 171 | ); 172 | } 173 | 174 | function MenuItem({ 175 | data, 176 | index, 177 | setMeasuredHeight, 178 | }) { 179 | const ref = React.useRef(null); 180 | 181 | // using useLayoutEffect prevents bounciness of options of re-renders 182 | React.useLayoutEffect(() => { 183 | if (ref.current) { 184 | const measuredHeight = ref.current.getBoundingClientRect().height; 185 | 186 | setMeasuredHeight({ index, measuredHeight }); 187 | } 188 | }, [ref.current]); 189 | 190 | return ( 191 | 195 | {data} 196 | 197 | ); 198 | } 199 | export default MenuList; 200 | -------------------------------------------------------------------------------- /src/WindowedSelect.tsx: -------------------------------------------------------------------------------- 1 | import MenuList from './MenuList'; 2 | import * as React from 'react'; 3 | import Select, { Props as SelectProps } from 'react-select'; 4 | import { calcOptionsLength } from './util'; 5 | 6 | interface WindowedSelectProps extends SelectProps { 7 | windowThreshold: number 8 | } 9 | 10 | function WindowedSelect ({ windowThreshold = 100, ...passedProps }: WindowedSelectProps, ref) { 11 | const optionsLength = React.useMemo( 12 | () => calcOptionsLength(passedProps.options), 13 | [passedProps.options] 14 | ); 15 | const isWindowed = optionsLength >= windowThreshold; 16 | 17 | return ( 18 | 30 | ); 31 | } 32 | 33 | export default React.forwardRef(WindowedSelect); -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import WindowedSelect from './WindowedSelect'; 2 | 3 | export * from 'react-select'; 4 | export { default as WindowedMenuList } from './MenuList'; 5 | 6 | export default WindowedSelect; 7 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export function calcOptionsLength (options) { 4 | options = options || []; 5 | const head = options[0] || {}; 6 | const isGrouped = head.options !== undefined; 7 | 8 | return isGrouped 9 | ? options.reduce((result, group) => result + group.options.length, 0) 10 | : options.length; 11 | } 12 | 13 | export function flattenGroupedChildren(children) { 14 | return children.reduce((result, child) => { 15 | if (child.props.children != null && typeof child.props.children === "string") { 16 | return [...result, child]; 17 | } else { 18 | const { 19 | props: { children: nestedChildren = [] }, 20 | } = child; 21 | 22 | return [...result, React.cloneElement(child, { type: "group" }, []), ...nestedChildren]; 23 | } 24 | }, []); 25 | } 26 | 27 | export function isFocused ({ props: { isFocused } }) { 28 | return isFocused === true; 29 | } 30 | 31 | export function getCurrentIndex (children) { 32 | return Math.max( 33 | children.findIndex(isFocused), 34 | 0 35 | ); 36 | } 37 | 38 | export function createGetHeight ({ 39 | groupHeadingStyles, 40 | noOptionsMsgStyles, 41 | optionStyles, 42 | loadingMsgStyles, 43 | }) { 44 | return function getHeight (child) { 45 | const { 46 | props: { 47 | type, 48 | children, 49 | inputValue, 50 | selectProps: { 51 | noOptionsMessage, 52 | loadingMessage, 53 | }, 54 | } 55 | } = child; 56 | 57 | if (type === 'group') { 58 | const { height = 25 } = groupHeadingStyles; 59 | return height; 60 | } 61 | else if (type === 'option') { 62 | const { height = 35 } = optionStyles; 63 | return height; 64 | } 65 | else if (typeof noOptionsMessage === 'function' && children === noOptionsMessage({ inputValue })) { 66 | const { height = 35 } = noOptionsMsgStyles; 67 | return height; 68 | } 69 | else if (typeof loadingMessage === 'function' && children === loadingMessage({ inputValue })) { 70 | const { height = 35 } = loadingMsgStyles; 71 | return height; 72 | } 73 | else { 74 | return 35; 75 | } 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /stories/AsyncSelect.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { storiesOf } from '@storybook/react'; 4 | import AsyncSelect from 'react-select/async'; 5 | 6 | import { WindowedMenuList } from '../src'; 7 | 8 | const colourOptions = [ 9 | { value: 'ocean', label: 'Ocean', color: '#00B8D9', isFixed: true }, 10 | { value: 'blue', label: 'Blue', color: '#0052CC', isDisabled: true }, 11 | { value: 'purple', label: 'Purple', color: '#5243AA' }, 12 | { value: 'red', label: 'Red', color: '#FF5630', isFixed: true }, 13 | { value: 'orange', label: 'Orange', color: '#FF8B00' }, 14 | { value: 'yellow', label: 'Yellow', color: '#FFC400' }, 15 | { value: 'green', label: 'Green', color: '#36B37E' }, 16 | { value: 'forest', label: 'Forest', color: '#00875A' }, 17 | { value: 'slate', label: 'Slate', color: '#253858' }, 18 | { value: 'silver', label: 'Silver', color: '#666666' }, 19 | { value: 'silver', label: 'Silver', color: '#666666' }, 20 | { value: 'silver', label: 'Silver', color: '#666666' }, 21 | { value: 'silver', label: 'Silver', color: '#666666' }, 22 | { value: 'silver', label: 'Silver', color: '#666666' }, 23 | { value: 'silver', label: 'Silver', color: '#666666' }, 24 | { value: 'silver', label: 'Silver', color: '#666666' }, 25 | { value: 'silver', label: 'Silver', color: '#666666' }, 26 | { value: 'silver', label: 'Silver', color: '#666666' }, 27 | { value: 'silver', label: 'Silver', color: '#666666' }, 28 | { value: 'silver', label: 'Silver', color: '#666666' }, 29 | { value: 'silver', label: 'Silver', color: '#666666' }, 30 | ]; 31 | 32 | const filterColors = (inputValue) => { 33 | return colourOptions.filter(i => 34 | i.label.toLowerCase().includes(inputValue.toLowerCase()) 35 | ); 36 | }; 37 | 38 | const promiseOptions = inputValue => 39 | new Promise(resolve => { 40 | setTimeout(() => { 41 | resolve(filterColors(inputValue)); 42 | }, 1000); 43 | }); 44 | 45 | storiesOf('Async Select', module) 46 | .add('default', () => ( 47 | 53 | )); -------------------------------------------------------------------------------- /stories/CreatableSelect.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { storiesOf } from '@storybook/react'; 4 | import CreatableSelect from 'react-select/creatable'; 5 | 6 | import { WindowedMenuList } from '../src'; 7 | 8 | import { groupedOptions, options200 } from './storyUtil'; 9 | 10 | storiesOf('Creatable Select', module) 11 | .add('default', () => ( 12 | { 17 | console.group('Value Changed'); 18 | console.log(newValue); 19 | console.log(`action: ${actionMeta.action}`); 20 | console.groupEnd(); 21 | }} 22 | onInputChange={(inputValue, actionMeta) => { 23 | console.group('Input Changed'); 24 | console.log(inputValue); 25 | console.log(`action: ${actionMeta.action}`); 26 | console.groupEnd(); 27 | }} 28 | /> 29 | )).add('grouped', () => ( 30 | { 35 | console.group('Value Changed'); 36 | console.log(newValue); 37 | console.log(`action: ${actionMeta.action}`); 38 | console.groupEnd(); 39 | }} 40 | onInputChange={(inputValue, actionMeta) => { 41 | console.group('Input Changed'); 42 | console.log(inputValue); 43 | console.log(`action: ${actionMeta.action}`); 44 | console.groupEnd(); 45 | }} 46 | /> 47 | )) 48 | -------------------------------------------------------------------------------- /stories/Select.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { storiesOf } from '@storybook/react'; 4 | 5 | import { options1 } from './storyUtil'; 6 | import { options200 } from './storyUtil'; 7 | import { groupedOptions } from './storyUtil'; 8 | import { optionsLongLabel } from './storyUtil'; 9 | import WindowedSelect from '../src'; 10 | 11 | function StoryWrapper (props) { 12 | return ( 13 | <> 14 | Windowed: 15 | 19 | 20 | Not windowed: 21 | 22 | > 23 | ); 24 | } 25 | 26 | storiesOf('Select', module) 27 | .add('Default', () => ( 28 | 33 | )) 34 | .add('grouped', () => ( 35 | 41 | )) 42 | .add('1 option', () => ( 43 | 49 | )) 50 | .add('no options msg (with dynamic input value)', () => ( 51 | `No ${inputValue !== '' ? `${inputValue} ` : ''}options`} 57 | /> 58 | )) 59 | .add('loading msg (with dynamic input value', () => ( 60 | `Loading ${inputValue}...`} 65 | menuIsOpen 66 | options={[]} 67 | /> 68 | )) 69 | .add('custom styles/height', () => ( 70 | ({ 75 | ...base, 76 | fontSize: 20, 77 | height: 40, 78 | }), 79 | menuList: (base) => ({ 80 | ...base, 81 | maxHeight: 200, 82 | }) 83 | }} 84 | /> 85 | )) 86 | .add('custom styles/height & grouped', () => ( 87 | ({ 92 | ...base, 93 | height: 100, 94 | }), 95 | option: (base) => ({ 96 | ...base, 97 | fontSize: 20, 98 | height: 40, 99 | }), 100 | }} 101 | /> 102 | )) 103 | .add('long label text', () => ( 104 | 105 | Don't explicitly set an option height in the styles prop if you want a dynamic/variable height for options with long labels. 106 | 111 | 112 | )) 113 | .add('custom styles + long label text', () => ( 114 | ({ 119 | ...base, 120 | fontSize: 20, 121 | paddingTop: 20, 122 | paddingBottom: 20, 123 | }), 124 | menuList: (base) => ({ 125 | ...base, 126 | maxHeight: 200, 127 | }) 128 | }} 129 | /> 130 | )); -------------------------------------------------------------------------------- /stories/storyUtil.js: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | 3 | export const createOptions = R.map(x => ({ value: x, label: `Option ${x}` })); 4 | 5 | export const options1 = createOptions(R.range(1,2)); 6 | export const options50 = createOptions(R.range(1, 51)); 7 | export const options200 = createOptions(R.range(1, 201)); 8 | 9 | export const options1K = createOptions(R.range(1, 1001)); 10 | export const options5K = createOptions(R.range(1, 5001)); 11 | export const options10K = createOptions(R.range(1, 10001)); 12 | 13 | export const options100K = createOptions(R.range(1, 100001)); 14 | export const options1M = createOptions(R.range(1, 1000001)); 15 | 16 | export const groupedOptions = [ 17 | { label: `Group 1`, options: createOptions(R.range(1, 11)) }, 18 | { label: `Group 2`, options: createOptions(R.range(11, 21)) }, 19 | { label: `Group 3`, options: createOptions(R.range(21, 31)) }, 20 | ]; 21 | 22 | export const optionsLongLabel = R.pipe( 23 | R.map(x => ({ value: x, label: `Option ${x}` })), 24 | R.insert(3, { value: 'long', label: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum' }) 25 | )(R.range(0, 15)); -------------------------------------------------------------------------------- /tests/MenuList.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MenuList from '../src/MenuList'; 3 | import { render } from '@testing-library/react'; 4 | 5 | describe('MenuList', () => { 6 | afterEach(() => { 7 | jest.restoreAllMocks(); 8 | }); 9 | 10 | // test('calls resetAfterIndex when state.children.length === 1', () => { 11 | // const resetAfterIndex = jest.fn(); 12 | // 13 | // function mockSetListRef(ref) { 14 | // this.list = { resetAfterIndex }; 15 | // } 16 | // jest.spyOn(MenuList.prototype, 'setListRef').mockImplementationOnce(mockSetListRef); 17 | // 18 | // const MockComponent = () => (); 19 | // const children = [React.createElement(MockComponent, { key: 1 })]; 20 | // const props = { 21 | // getStyles () { 22 | // return { 23 | // maxHeight: 200, 24 | // } 25 | // } 26 | // }; 27 | // const wrapper = mount({children}); 28 | // wrapper.setState({ children: [React.createElement(MockComponent, { key: 1 })]}); 29 | // 30 | // expect(resetAfterIndex.mock.calls.length).toBe(1); 31 | // }); 32 | 33 | const MockComponent = () => (); 34 | const children = [ 35 | React.createElement(MockComponent, { key: 1, selectProps: {} }), 36 | ]; 37 | 38 | test('add class name prefix to menu list', () => { 39 | const props = { 40 | selectProps: { 41 | classNamePrefix: 'foo', 42 | }, 43 | getStyles () { 44 | return { 45 | maxHeight: 200, 46 | } 47 | }, 48 | }; 49 | const { container } = render({children}); 50 | 51 | expect(container.firstChild.className).toMatch('foo__menu-list'); 52 | }); 53 | 54 | test('add class name prefix to menu list when isMulti is true', () => { 55 | const props = { 56 | selectProps: { 57 | classNamePrefix: 'foo', 58 | isMulti: true, 59 | }, 60 | getStyles () { 61 | return { 62 | maxHeight: 200, 63 | } 64 | }, 65 | }; 66 | const { container } = render({children}); 67 | 68 | expect(container.firstChild.className).toMatch('foo__menu-list--is-multi'); 69 | }) 70 | }); 71 | -------------------------------------------------------------------------------- /tests/WindowedSelect.spec.js: -------------------------------------------------------------------------------- 1 | import Select, { components } from 'react-select'; 2 | import { mount, shallow } from 'enzyme'; 3 | import React from 'react'; 4 | import WindowedMenuList from '../src/MenuList'; 5 | import WindowedSelect from '../src/WindowedSelect'; 6 | 7 | const { MenuList } = components; 8 | 9 | describe('WindowedSelect', () => { 10 | test('passes props to Select component', () => { 11 | const selectWrapper = shallow(); 12 | 13 | expect(selectWrapper.find(Select).prop('foo')).toBeTruthy(); 14 | expect(selectWrapper.find(Select).prop('bar')).toBeTruthy(); 15 | }); 16 | 17 | test('handles nil options', () => { 18 | expect(() => shallow()).not.toThrow(); 19 | expect(() => shallow()).not.toThrow(); 20 | expect(() => shallow()).not.toThrow(); 21 | }); 22 | 23 | test('renders a windowed menu when options length > windowThreshold', () => { 24 | const options = [ 25 | { label: 'foo', value: 1 }, 26 | ]; 27 | 28 | let selectWrapper = mount( 29 | 34 | ); 35 | 36 | expect(selectWrapper.find(MenuList).exists()).toBeFalsy(); 37 | expect(selectWrapper.find(WindowedMenuList).exists()).toBeTruthy(); 38 | }); 39 | 40 | test('renders a windowed menu when options length === windowThreshold', () => { 41 | const options = [ 42 | { label: 'foo', value: 1 }, 43 | ]; 44 | 45 | let selectWrapper = mount( 46 | 51 | ); 52 | 53 | expect(selectWrapper.find(MenuList).exists()).toBeFalsy(); 54 | expect(selectWrapper.find(WindowedMenuList).exists()).toBeTruthy(); 55 | }); 56 | 57 | test('renders a non-windowed menu when options length < windowThreshold', () => { 58 | const options = [ 59 | { label: 'foo', value: 1 }, 60 | ]; 61 | 62 | let selectWrapper = mount( 63 | 68 | ); 69 | expect(selectWrapper.find(MenuList).exists()).toBeTruthy(); 70 | expect(selectWrapper.find(WindowedMenuList).exists()).toBeFalsy(); 71 | }); 72 | 73 | test('forwards ref', () => { 74 | let windowedSelectRef; 75 | let selectRef; 76 | 77 | mount( 78 | windowedSelectRef = x} 81 | /> 82 | ); 83 | 84 | mount( selectRef = x}/>); 85 | 86 | expect(selectRef.state.prevProps.placeholder).toEqual(windowedSelectRef.state.prevProps.placeholder); 87 | expect(selectRef.context).toEqual(windowedSelectRef.context); 88 | expect(selectRef.refs).toEqual(windowedSelectRef.refs); 89 | expect(selectRef.updater).toEqual(windowedSelectRef.updater); 90 | }) 91 | }); 92 | -------------------------------------------------------------------------------- /tests/setupTests.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | Enzyme.configure({ adapter: new Adapter() }); -------------------------------------------------------------------------------- /tests/storyshots.spec.js: -------------------------------------------------------------------------------- 1 | import initStoryshots, { renderOnly } from '@storybook/addon-storyshots'; 2 | 3 | initStoryshots({ 4 | test: renderOnly, 5 | }); -------------------------------------------------------------------------------- /tests/util.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | calcOptionsLength, 3 | createGetHeight, 4 | flattenGroupedChildren, 5 | getCurrentIndex, 6 | isFocused, 7 | } from '../src/util'; 8 | import React from 'react'; 9 | 10 | describe(`util`, () => { 11 | describe(`calcOptionsLength`, () => { 12 | it(`should calculate options length`, () => { 13 | const options = [1,2,3]; 14 | expect(calcOptionsLength(options)).toEqual(3); 15 | }); 16 | 17 | it(`should calculate grouped options length`, () => { 18 | const groupedOptions = [ 19 | { options: [1,2,3] }, 20 | { options: [4,5,6] }, 21 | { options: [7,8,9] }, 22 | ]; 23 | expect(calcOptionsLength(groupedOptions)).toEqual(9); 24 | }); 25 | 26 | it(`should handle nil options`, () => { 27 | expect(() => calcOptionsLength()).not.toThrow(); 28 | expect(() => calcOptionsLength(undefined)).not.toThrow(); 29 | expect(() => calcOptionsLength(null)).not.toThrow(); 30 | }); 31 | }); 32 | 33 | describe(`createGetHeight`, () => { 34 | const groupHeadingStyles = { height: 0 }; 35 | const optionStyles = { height: 1 }; 36 | const noOptionsMsgStyles = { height: 2 }; 37 | const loadingMsgStyles = { height: 3 }; 38 | 39 | const getHeight = createGetHeight({ 40 | groupHeadingStyles, 41 | optionStyles, 42 | noOptionsMsgStyles, 43 | loadingMsgStyles, 44 | }); 45 | 46 | const defaultChildProps = { 47 | children: '', 48 | inputValue: '', 49 | selectProps: { 50 | noOptionsMessage: () => {}, 51 | loadingMessage: () => {}, 52 | }, 53 | }; 54 | 55 | test(`returns group height`, () => { 56 | const child = { 57 | props: { 58 | ...defaultChildProps, 59 | type: 'group', 60 | } 61 | }; 62 | 63 | expect(getHeight(child)).toEqual(0); 64 | }); 65 | 66 | test(`returns option height`, () => { 67 | const child = { 68 | props: { 69 | type: 'option', 70 | ...defaultChildProps, 71 | }, 72 | }; 73 | 74 | expect(getHeight(child)).toEqual(1); 75 | }); 76 | 77 | test(`returns noOptionsMessage height`, () => { 78 | const child = { 79 | props: { 80 | ...defaultChildProps, 81 | children: 'No Options', 82 | selectProps: { 83 | ...defaultChildProps.selectProps, 84 | noOptionsMessage: () => 'No Options', 85 | }, 86 | }, 87 | }; 88 | 89 | expect(getHeight(child)).toEqual(2); 90 | }); 91 | 92 | test(`calls noOptionsMessage with inputValue`, () => { 93 | const noOptionsMessage = jest.fn(({ inputValue }) => inputValue); 94 | const child = { 95 | props: { 96 | ...defaultChildProps, 97 | children: 'Foo', 98 | inputValue: 'Foo', 99 | selectProps: { 100 | ...defaultChildProps.selectProps, 101 | noOptionsMessage, 102 | }, 103 | }, 104 | }; 105 | 106 | expect(getHeight(child)).toEqual(2); 107 | expect(noOptionsMessage.mock.calls[0][0]).toEqual({ inputValue: 'Foo' }); 108 | }); 109 | 110 | test(`returns loadingMessage height`, () => { 111 | const child = { 112 | props: { 113 | ...defaultChildProps, 114 | children: 'Loading...', 115 | selectProps: { 116 | ...defaultChildProps.selectProps, 117 | loadingMessage: () => 'Loading...', 118 | }, 119 | }, 120 | }; 121 | 122 | expect(getHeight(child)).toEqual(3); 123 | }); 124 | 125 | test(`calls loadingMessage with inputValue`, () => { 126 | const loadingMessage = jest.fn(({ inputValue }) => inputValue); 127 | const child = { 128 | props: { 129 | ...defaultChildProps, 130 | children: 'Foo', 131 | inputValue: 'Foo', 132 | selectProps: { 133 | ...defaultChildProps.selectProps, 134 | loadingMessage, 135 | }, 136 | }, 137 | }; 138 | 139 | expect(getHeight(child)).toEqual(3); 140 | expect(loadingMessage.mock.calls[0][0]).toEqual({ inputValue: 'Foo' }); 141 | }); 142 | 143 | test(`returns default height`, () => { 144 | expect(getHeight({ props: defaultChildProps })).toEqual(35); 145 | }); 146 | 147 | describe(`when no height in custom styles`, () => { 148 | const getHeight = createGetHeight({ 149 | groupHeadingStyles: {}, 150 | optionStyles: {}, 151 | noOptionsMsgStyles: {}, 152 | loadingMsgStyles: {}, 153 | }); 154 | 155 | test(`returns default height when no height in loadingMessage styles`, () => { 156 | expect(getHeight({ 157 | props: { 158 | ...defaultChildProps, 159 | children: 'Loading...', 160 | selectProps: { 161 | ...defaultChildProps.selectProps, 162 | loadingMessage: () => 'Loading...', 163 | }, 164 | }, 165 | })).toEqual(35); 166 | }); 167 | 168 | test(`returns default height when no height in noOptionsMessage styles`, () => { 169 | expect(getHeight({ 170 | props: { 171 | ...defaultChildProps, 172 | children: 'No Options', 173 | selectProps: { 174 | ...defaultChildProps.selectProps, 175 | noOptionsMessage: () => 'No Options', 176 | }, 177 | }, 178 | })).toEqual(35); 179 | }); 180 | 181 | test(`returns default height when no height in option styles`, () => { 182 | expect(getHeight({ 183 | props: { 184 | ...defaultChildProps, 185 | type: 'option', 186 | }, 187 | })).toEqual(35); 188 | }); 189 | 190 | test(`returns default height when no height in group styles`, () => { 191 | expect(getHeight({ 192 | props: { 193 | ...defaultChildProps, 194 | type: 'group', 195 | }, 196 | })).toEqual(25); 197 | }); 198 | }); 199 | }); 200 | 201 | describe(`flattenGroupedChildren`, () => { 202 | const TestOption = React.createElement('div', { key: 'key' }); 203 | const TestGroup = React.createElement( 204 | 'div', 205 | {}, 206 | [TestOption, TestOption] 207 | ); 208 | 209 | test(`flattens grouped children one level deep`, () => { 210 | const children = flattenGroupedChildren([TestGroup]); 211 | expect(children).toEqual([ 212 | React.cloneElement(TestGroup, { type: 'group' }, []), 213 | TestOption, 214 | TestOption, 215 | ]) 216 | }); 217 | 218 | test(`negative: handles nil nested children`, () => { 219 | const Test = React.createElement('div'); 220 | expect(() => flattenGroupedChildren([Test])).not.toThrow(); 221 | }); 222 | }); 223 | 224 | describe(`getCurrentIndex`, () => { 225 | test(`returns focused item index when item is focused`, () => { 226 | const children = [ 227 | { props: { isFocused: false } }, 228 | { props: { isFocused: true } }, 229 | ]; 230 | expect(getCurrentIndex(children)).toEqual(1); 231 | }); 232 | 233 | test(`returns 0 when no item is focused`, () => { 234 | const children = [ 235 | { props: { isFocused: false } }, 236 | { props: { isFocused: false } }, 237 | ]; 238 | expect(getCurrentIndex(children)).toEqual(0); 239 | }); 240 | }); 241 | 242 | describe(`isFocused`, () => { 243 | test(`returns true when isFocused is true`, () => { 244 | const item = { 245 | props: { 246 | isFocused: true, 247 | }, 248 | }; 249 | expect(isFocused(item)).toEqual(true); 250 | }); 251 | 252 | test(`returns false when isFocused is not true`, () => { 253 | expect(isFocused({ props: { isFocused: false } })).toEqual(false); 254 | 255 | expect(isFocused({ props: {} })).toEqual(false); 256 | }); 257 | }); 258 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", // path to output directory 4 | "sourceMap": true, // allow sourcemap support 5 | "declaration": true, // generate TS typings 6 | "strictNullChecks": true, // enable strict null checks as a best practice 7 | "module": "es6", // specify module code generation 8 | "jsx": "react", // use typescript to transpile jsx to js 9 | "target": "es5", // specify ECMAScript target version 10 | "allowJs": true, // allow a partial TypeScript and JavaScript codebase 11 | "moduleResolution": "node" 12 | }, 13 | "include": [ 14 | "./src/" 15 | ] 16 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './src/index.ts', 5 | output: { 6 | filename: 'main.js', 7 | path: path.resolve(__dirname, 'dist'), 8 | library: 'WindowedSelect', 9 | libraryTarget: 'commonjs2', 10 | }, 11 | module: { 12 | rules: [ 13 | { test: /\.(t|j)sx?$/, use: { loader: 'ts-loader' }, exclude: /node_modules/ }, 14 | ] 15 | }, 16 | resolve: { 17 | extensions: ['.ts', '.tsx', '.js', '.jsx'] 18 | }, 19 | externals: { 20 | react: 'commonjs react', 21 | 'react-dom': 'commonjs react-dom', 22 | 'react-select': 'commonjs react-select', 23 | 'react-window': 'commonjs react-window', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /webpack.demo.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | entry: './demo/index.js', 6 | output: { 7 | filename: 'main.js', 8 | path: path.resolve(__dirname, 'demo/dist'), 9 | }, 10 | module: { 11 | rules: [ 12 | { test: /\.(t|j)sx?$/, use: { loader: 'ts-loader' }, exclude: /node_modules/ }, 13 | ] 14 | }, 15 | resolve: { 16 | extensions: ['.ts', '.tsx', '.js', '.jsx'] 17 | }, 18 | plugins: [new HtmlWebpackPlugin({ 19 | template: path.join(__dirname, "demo/index.html"), 20 | filename: "./index.html", 21 | })] 22 | }; --------------------------------------------------------------------------------
Don't explicitly set an option height in the styles prop if you want a dynamic/variable height for options with long labels.