├── .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 | ![example workflow](https://github.com/jacobworrel/react-windowed-select/actions/workflows/node.js.yml/badge.svg) 4 | [![npm package][npm-badge]][npm] 5 | [![Coverage Status](https://coveralls.io/repos/github/jacobworrel/react-windowed-select/badge.svg?branch=master)](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 | [![Edit react-windowed-select](https://codesandbox.io/static/img/play-codesandbox.svg)](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 | [![Edit react-windowed-select custom filter and component](https://codesandbox.io/static/img/play-codesandbox.svg)](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 | 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 | }; --------------------------------------------------------------------------------