├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── README_BUSINESS_RULES.md ├── package-lock.json ├── package.json ├── src ├── Extras.tsx ├── __mocks__ │ └── options.ts ├── __tests__ │ ├── keyboard-multi-select.test.tsx │ ├── keyboard-single-select.test.tsx │ └── test.test.tsx ├── components │ ├── MultiSelect.tsx │ ├── MultiSelectOption.tsx │ ├── SingleSelect.tsx │ └── SingleSelectOption.tsx ├── constants │ ├── actionTypes.ts │ └── keyCodes.ts ├── docs │ ├── api.md │ ├── home.md │ ├── multiselect │ │ ├── basic.md │ │ ├── disabled.md │ │ ├── noSelectionLabel.md │ │ └── optHeader.md │ ├── recipes │ │ ├── ControlledExample2App.tsx │ │ ├── ControlledExample2Form.tsx │ │ ├── ControlledExample2MockProps.ts │ │ ├── controlled-example-1.md │ │ ├── controlled-example-2.md │ │ ├── formik.md │ │ ├── onListen.md │ │ └── onSelectOnDeselect.md │ ├── screen-reader-demo.md │ └── singleselect │ │ ├── basic.md │ │ ├── customLabelRenderer.md │ │ ├── disabled.md │ │ ├── noSelectionLabel.md │ │ ├── optHeader.md │ │ └── option-markup.md ├── index.ts ├── lib │ ├── containsClassName.ts │ ├── eventHandlers │ │ ├── handleAlphaNumerical.ts │ │ ├── handleBlur.ts │ │ ├── handleClick.ts │ │ ├── handleEnterPressed.ts │ │ ├── handleKeyEvent.ts │ │ ├── handleKeyUpOrDownPressed.ts │ │ ├── handleTouchMove.ts │ │ ├── handleTouchStart.ts │ │ └── index.ts │ ├── getCustomLabelText.ts │ ├── getNextIndex.ts │ ├── isEqual.ts │ ├── nextValidIndex.ts │ ├── onChangeBroadcasters │ │ ├── index.ts │ │ ├── multiSelectBroadcastChange.ts │ │ └── singleSelectBroadcastChange.ts │ └── preventDefaultForKeyCodes.ts ├── react-responsive-select.css ├── react-responsive-select.tsx ├── reducers │ ├── initialState.ts │ ├── lib │ │ ├── addMultiSelectIndex.ts │ │ ├── addMultiSelectOption.ts │ │ ├── getInitialMultiSelectOption.ts │ │ ├── getMultiSelectInitialSelectedOptions.ts │ │ ├── getMultiSelectSelectedValueIndexes.ts │ │ ├── getSelectedValueIndex.ts │ │ ├── getSingleSelectSelectedOption.ts │ │ ├── index.ts │ │ ├── mergeIsAlteredState.ts │ │ ├── removeMultiSelectIndex.ts │ │ ├── removeMultiSelectOption.ts │ │ └── resetMultiSelectState.ts │ └── reducer.ts ├── styleguide │ ├── StyleguidistStyle.css │ └── Wrapper.tsx └── types │ └── index.tsx ├── styleguide.config.js ├── styleguide ├── build │ ├── bundle.04c1dcbf.js │ └── bundle.04c1dcbf.js.LICENSE.txt └── index.html ├── tsconfig.json ├── tsconfig.styleguidist.json └── workflows └── main.yml /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10.5.0" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ben Bowes 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-responsive-select 2 | 3 | A customisable, touchable, React single-select / multi-select form control. 4 | 5 | Built with keyboard and screen reader accessibility in mind. 6 | 7 | ## Features 8 | 9 | - Single and Multi select modes 10 | - Accessible WAI ARIA compliance 11 | - Touch friendly 12 | - Keyboard friendly 13 | - Similar interaction experience across platforms 14 | - Custom label rendering 15 | - Custom option markup 16 | - Option headers 17 | - Mimics keyboard functionality where possible (sans multiselect) 18 | - Easy slot-in to your design system 19 | - It's about 25kb 20 | 21 | ## Recommendation 22 | 23 | > This select component was created many years ago when there was no React WCAG select available. Now there are plenty. I suggest using one of those, their communities and contributer lists are larger. 24 | > I will continue to run patches on this one in the foreseeable future for security upgrades if any issues arise. 25 | 26 | ## Getting started 27 | 28 | Install the dependency - https://www.npmjs.com/package/react-responsive-select 29 | 30 | `npm install react-responsive-select --save-dev` 31 | 32 | Example usage (Single Select): 33 | 34 | ```jsx 35 | import React from 'react'; 36 | import { Select, CaretIcon, ModalCloseButton } from 'react-responsive-select'; 37 | 38 | // for default styles... 39 | import 'react-responsive-select/dist/react-responsive-select.css'; 40 | 41 | const Form = () => ( 42 |
43 | } 83 | options={[ 84 | { 85 | value: 'any', 86 | text: 'Any', 87 | markup: , 88 | }, 89 | { 90 | value: 'fiat', 91 | text: 'Fiat', 92 | markup: , 93 | }, 94 | { 95 | value: 'subaru', 96 | text: 'Subaru', 97 | markup: , 98 | }, 99 | { 100 | value: 'suzuki', 101 | text: 'Suzuki', 102 | markup: , 103 | }, 104 | ]} 105 | caretIcon={} 106 | onChange={(...rest) => console.log(rest)} 107 | onSubmit={() => console.log('onSubmit')} 108 | /> 109 | 110 | ); 111 | ``` 112 | 113 | ## Examples & Demo 114 | 115 | https://benbowes.github.io/react-responsive-select/ 116 | 117 | ## API 118 | 119 | https://benbowes.github.io/react-responsive-select/#/API 120 | 121 | ## Screen Reader Demo 122 | 123 | https://benbowes.github.io/react-responsive-select/#/Screen%20reader%20demo 124 | 125 | ## Business Rules 126 | 127 | Have a read of [README_BUSINESS_RULES.md](./README_BUSINESS_RULES.md) 128 | 129 | ## Upgrade from v6 - v7 130 | 131 | From version 7.0.0 on, you will need to use a `key` prop to update react-responsive-select's internal state. More on that here: https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#recommendation-fully-uncontrolled-component-with-a-key 132 | 133 | There are some examples in the recipe section here: https://benbowes.github.io/react-responsive-select/ 134 | -------------------------------------------------------------------------------- /README_BUSINESS_RULES.md: -------------------------------------------------------------------------------- 1 | # Business Rules 2 | 3 | ## Single-Select Desktop Keyboard 4 | 5 | ##### When not focused 6 | 7 | - Select Input receives focus when **TABBED** to. 8 | 9 | ##### When focused and closed 10 | 11 | - hitting **TAB** key should blur Select Input. 12 | 13 | - hitting **DOWN** key should open the options panel and highlight **SELECTED ITEM**. 14 | 15 | - hitting **UP** key should open the options panel and highlight **SELECTED ITEM**. 16 | 17 | - hitting **SPACE** key should open the options panel and highlight **SELECTED ITEM**. 18 | 19 | - hitting **ENTER** key should submit the form. 20 | 21 | - hitting **a-z or 0-9** keys in quick succession (250ms) should open the options panel and highlight first item that starts with key pressed character/s (must be a **FOCUSSABLE ITEM**). 22 | 23 | ##### When focused and open 24 | 25 | - hitting **TAB** key should not blur the Select Input. 26 | 27 | - hitting **DOWN** key should decrement down the options panel - highlighting next potential selection (must be a **FOCUSSABLE ITEM**). When the bottom of the options list is reached, it cycles the next potential selection up to the top of the list. It cycles infinitely. 28 | 29 | - hitting **UP** key should increment up the options panel - highlighting next potential selection (must be a **FOCUSSABLE ITEM**). When the top of the options list is reached, it cycles the next potential selection to the bottom of the list. It cycles infinitely. 30 | 31 | - hitting **a-z or 0-9** keys in quick succession (250ms) should highlight first item that starts with key pressed character/s (must be a **FOCUSSABLE ITEM**). 32 | 33 | - hitting **ENTER** key should select the current highlighted option and close the options panel, but not blur the Select Input. 34 | 35 | - hitting **SPACE** key should select the current highlighted option and close the options panel, but not blur the Select Input. 36 | 37 | - hitting **ESC** key should close the options panel and keep the user's last selection, or the initial selection, but not blur the Select Input. 38 | 39 | --- 40 | 41 | ## Single-Select Clickable/Touch Device, As above with these ammendments 42 | 43 | - **TAPPING** on a select will open it's options. 44 | 45 | - **CLICKING** on a select will open it's options. 46 | 47 | - **TAPPING** on an option will select it's value. 48 | 49 | - **CLICKING** on an option will select it's value. 50 | 51 | - **DRAGGING** on an options panel that is scrollable, will scroll the options panel. 52 | 53 | - **SCROLLING** on an options panel that is scrollable, will scroll the options panel. 54 | 55 | --- 56 | 57 | ## Multi-Select Desktop Keyboard 58 | 59 | #### When not focused 60 | 61 | - Select Input receives focus when **TABBED** to. 62 | 63 | #### When focused and closed 64 | 65 | - hitting **TAB** key should blur Select Input. 66 | 67 | - hitting **DOWN** key should open the options panel and highlight **SELECTED ITEM**. 68 | 69 | - hitting **UP** key should open the options panel and highlight **SELECTED ITEM**. 70 | 71 | - hitting **SPACE** key should open the options panel and highlight **SELECTED ITEM**. 72 | 73 | - hitting **ENTER** key should submit the form. 74 | 75 | - hitting **a-z or 0-9** keys in quick succession (250ms) should open the options panel and highlight first option that starts with key pressed character/s (must be a **FOCUSSABLE ITEM**). 76 | 77 | #### When focused and open 78 | 79 | - hitting **TAB** key should close options panel but retain focus on the select. 80 | 81 | - hitting **DOWN** key should decrement down the options panel - highlighting next potential selection (must be a **FOCUSSABLE ITEM**). When the bottom of the options list is reached, it cycles the next potential selection up to the top of the list. It cycles infinitely. 82 | 83 | - hitting **UP** key should increment up the options panel - highlighting next potential selection (must be a **FOCUSSABLE ITEM**). When the top of the options list is reached, it cycles the next potential selection to the bottom of the list. It cycles infinitely. 84 | 85 | - hitting **a-z or 0-9** keys in quick succession (250ms) should highlight first item that starts with key pressed character/s (must be a **FOCUSSABLE ITEM**). 86 | 87 | - hitting **ENTER** key should select/unselect the current highlighted option. 88 | 89 | - hitting **SPACE** key should select/unselect the current highlighted option. 90 | 91 | - hitting **ESC** key should close the options panel and keep the user's last selection, or the initial selection, but not blur the Select Input. 92 | 93 | --- 94 | 95 | ## Multi-Select Clickable/Touch Device, As above with these ammendments 96 | 97 | - **TAPPING** on a select will open it's options. 98 | 99 | - **CLICKING** on a select will open it's options. 100 | 101 | - **TAPPING** on an option will check/uncheck it's value. 102 | 103 | - **CLICKING** on an option will check/uncheck it's value. 104 | 105 | - **DRAGGING** on an options panel that is scrollable, will scroll the options panel. 106 | 107 | - **SCROLLING** on an options panel that is scrollable, will scroll the options panel. 108 | 109 | --- 110 | 111 | ##### Terms definition 112 | 113 | - **SELECTED ITEM** is the item set with selectedValue or the first **FIRST AVAILABLE OPTION**. 114 | 115 | - **FIRST AVAILABLE OPTION** is the first option in the options list that is selectable. E.g: 116 | 117 | - Is not an option header. 118 | - Is not disabled. 119 | 120 | - **FOCUSSABLE ITEM** is an option in the options list that is foccussable. E.g: 121 | - Is not an option header. 122 | - Is not disabled. 123 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-responsive-select", 3 | "version": "7.0.8", 4 | "license": "MIT", 5 | "main": "dist/index.js", 6 | "typings": "dist/index.d.ts", 7 | "module": "dist/react-responsive-select.esm.js", 8 | "author": "Ben Bowes ", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/benbowes/react-responsive-select.git" 12 | }, 13 | "homepage": "https://benbowes.github.io/react-responsive-select", 14 | "files": [ 15 | "dist" 16 | ], 17 | "engines": { 18 | "node": ">=10" 19 | }, 20 | "scripts": { 21 | "prepublishOnly": "npm run build", 22 | "start": "npx styleguidist server", 23 | "test": "tsdx test", 24 | "lint": "tsdx lint", 25 | "prepare": "tsdx build", 26 | "build:styleguide": "npx styleguidist build --ts", 27 | "build": "tsdx build --format cjs,esm,umd && npm run copyfiles && npm run deleteUnwantedDistEntries && npm run build:styleguide", 28 | "copyfiles": "cp src/react-responsive-select.css dist/react-responsive-select.css", 29 | "deleteUnwantedDistEntries": "rm -rf dist/docs && rm -rf dist/__mocks__" 30 | }, 31 | "husky": { 32 | "hooks": { 33 | "pre-commit": "tsdx lint" 34 | } 35 | }, 36 | "prettier": { 37 | "printWidth": 120, 38 | "semi": true, 39 | "singleQuote": true, 40 | "trailingComma": "es5" 41 | }, 42 | "keywords": [ 43 | "select", 44 | "react select", 45 | "react dropdown", 46 | "accessible react select", 47 | "component", 48 | "responsive", 49 | "mobile", 50 | "touch", 51 | "select", 52 | "dropdown", 53 | "accessible", 54 | "multiselect" 55 | ], 56 | "dependencies": { 57 | "singleline": "^2.0.0" 58 | }, 59 | "peerDependencies": { 60 | "react": ">=16" 61 | }, 62 | "devDependencies": { 63 | "@testing-library/react": "^10.4.5", 64 | "@types/react": "^16.9.42", 65 | "@types/react-dom": "^16.9.8", 66 | "css-loader": "^5.2.5", 67 | "formik": "^2.1.4", 68 | "husky": "^4.3.8", 69 | "react": "^16.13.1", 70 | "react-dom": "^16.13.1", 71 | "react-styleguidist": "^13.0.0", 72 | "style-loader": "^1.2.1", 73 | "ts-loader": "^9.4.2", 74 | "tsdx": "^0.14.1", 75 | "tslib": "^2.0.0", 76 | "typescript": "^3.9.6", 77 | "webpack": "^5.94.0", 78 | "yup": "^0.29.1" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Extras.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /* tslint:disable:max-line-length*/ 4 | export const CaretIcon = (props: any): React.ReactElement => ( 5 | 15 | 16 | 17 | 18 | 19 | ); 20 | 21 | export const ErrorIcon = (props: any): React.ReactElement => ( 22 | 32 | 33 | 34 | 35 | 36 | ); 37 | 38 | export const ModalCloseButton = (props: any): React.ReactElement => ( 39 |
40 | 48 | 49 | 50 |
51 | ); 52 | 53 | export const CheckboxIcon = (props: any): React.ReactElement => ( 54 | 64 | 65 | 66 | 67 | 68 | ); 69 | 70 | export const MultiSelectOptionMarkup = ({ text, ...props }: { text: string; props: any }): React.ReactElement => ( 71 |
72 | 73 | 74 | 75 | {text} 76 |
77 | ); 78 | /* tslint:enable:max-line-length */ 79 | -------------------------------------------------------------------------------- /src/__mocks__/options.ts: -------------------------------------------------------------------------------- 1 | export const BASIC_OPTIONS = [ 2 | { text: 'Cars', optHeader: true }, 3 | { value: 'null', text: 'Any' }, 4 | { value: 'alfa-romeo', text: 'Alfa Romeo' }, 5 | { value: 'bmw', text: 'BMW' }, 6 | { value: 'fiat', text: 'Fiat' }, 7 | { value: 'subaru', text: 'Subaru' }, 8 | { value: 'suzuki', text: 'Suzuki' }, 9 | { value: 'tesla', text: 'Tesla', disabled: true }, 10 | { value: 'volvo', text: 'Volvo' }, 11 | { value: 'zonda', text: 'Zonda' }, 12 | ]; 13 | 14 | export const MULTISELECT_OPTIONS = [ 15 | { value: 'null', text: 'Any' }, 16 | { value: 'alfa-romeo', text: 'Alfa Romeo' }, 17 | { value: 'bmw', text: 'BMW' }, 18 | { value: 'fiat', text: 'Fiat' }, 19 | { value: 'subaru', text: 'Subaru' }, 20 | { value: 'suzuki', text: 'Suzuki' }, 21 | { value: 'tesla', text: 'Tesla', disabled: true }, 22 | { value: 'volvo', text: 'Volvo' }, 23 | { value: 'zonda', text: 'Zonda' }, 24 | ]; 25 | -------------------------------------------------------------------------------- /src/__tests__/keyboard-multi-select.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, act, cleanup, fireEvent } from '@testing-library/react'; 3 | import { BASIC_OPTIONS, MULTISELECT_OPTIONS } from '../__mocks__/options'; 4 | import { Select } from '../'; 5 | 6 | /** 7 | * **SELECTED ITEM** is the item set with selectedValue or the first **FIRST AVAILABLE OPTION**. 8 | * 9 | * **FIRST AVAILABLE OPTION** is the first option in the options list that is selectable. E.g: 10 | * - Is not an option header. 11 | * - Is not disabled. 12 | * 13 | * **FOCUSSABLE ITEM** is an option in the options list that is foccussable. E.g: 14 | * - Is not an option header. 15 | * - Is not disabled 16 | */ 17 | 18 | afterEach(cleanup); 19 | 20 | describe('Keyboard MultiSelect', () => { 21 | describe('When not focused', () => { 22 | test('Select Input receives focus when **TABBED** to', () => { 23 | // TODO: FOCUS/BLUR NOT TESTED 24 | }); 25 | }); 26 | 27 | describe('When focused and closed', () => { 28 | test('hitting **TAB** key should blur Select Input', () => { 29 | // TODO: FOCUS/BLUR NOT TESTED 30 | }); 31 | 32 | test('hitting **DOWN** key should open the options panel and highlight **SELECTED ITEM**', () => { 33 | const wrapper = render(); 56 | 57 | const select = wrapper.getByTestId('cars'); 58 | const rrsOption9 = wrapper.getByTestId('rrs-option_cars_9'); 59 | 60 | // Focus 61 | act(() => select.focus()); 62 | 63 | // Hit up key 64 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 }); 65 | 66 | // Select is open 67 | expect(select.classList.contains('rrs')).toEqual(true); 68 | expect(select.classList.contains('rrs--options-visible')).toEqual(true); 69 | 70 | // Zonda is selected 71 | expect(rrsOption9.classList.contains('rrs__option')).toEqual(true); 72 | expect(rrsOption9.classList.contains('rrs__option--selected')).toEqual(true); 73 | expect(rrsOption9.classList.contains('rrs__option--next-selection')).toEqual(true); 74 | }); 75 | 76 | test('hitting **SPACE** key should open the options panel and highlight **SELECTED ITEM**', () => { 77 | const wrapper = render( 102 | ); 103 | 104 | const select = wrapper.getByTestId('cars'); 105 | 106 | // Focus 107 | act(() => select.focus()); 108 | 109 | // Hit enter key 110 | fireEvent.keyDown(select, { key: 'Enter', keyCode: 13 }); 111 | 112 | // onSubmit prop was called 113 | expect(submitSpy).toHaveBeenCalled(); 114 | }); 115 | 116 | test('hitting **a-z or 0-9** keys in quick succession (250ms) should open the options panel and highlight first item that starts with key pressed character/s (must be a **FOCUSSABLE ITEM**)', () => { 117 | jest.useFakeTimers(); 118 | 119 | const wrapper = render(); 146 | 147 | const select = wrapper.getByTestId('cars'); 148 | const rrsOption3 = wrapper.getByTestId('rrs-option_cars_3'); 149 | 150 | // Focus 151 | act(() => select.focus()); 152 | 153 | // Open panel 154 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 155 | 156 | // Start moving down the options... continue till cycled back to 4th option 157 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 158 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 159 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 160 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 161 | 162 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 163 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 164 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 165 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 166 | 167 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 168 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 169 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 170 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 171 | 172 | // Expect `BMW` to be highlighted 173 | expect(rrsOption3.classList.contains('rrs__option')).toEqual(true); 174 | expect(rrsOption3.classList.contains('rrs__option--next-selection')).toEqual(true); 175 | }); 176 | 177 | test('hitting **UP** key should increment up the options panel - highlighting next potential selection (must be a **FOCUSSABLE ITEM**). When the top of the options list is reached, it cycles the next potential selection to the bottom of the list. It cycles infinitely', () => { 178 | const wrapper = render(); 207 | 208 | const select = wrapper.getByTestId('cars'); 209 | const rrsOption3 = wrapper.getByTestId('rrs-option_cars_3'); 210 | 211 | // Focus 212 | act(() => select.focus()); 213 | 214 | // Open panel 215 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 }); 216 | 217 | // Hit `f` and `i` key 218 | fireEvent.keyDown(select, { key: 'f', keyCode: 70 }); 219 | fireEvent.keyDown(select, { key: 'i', keyCode: 73 }); 220 | 221 | jest.runTimersToTime(250); 222 | 223 | // `fiat` option was found and highlighted 224 | expect(rrsOption3.classList.contains('rrs__option')).toEqual(true); 225 | expect(rrsOption3.classList.contains('rrs__option--next-selection')).toEqual(true); 226 | }); 227 | 228 | test('hitting **ENTER** key should select the current highlighted option and close the options panel, but not blur the Select Input', () => { 229 | const submitSpy = jest.fn(); 230 | const wrapper = render( 268 | ); 269 | 270 | const select = wrapper.getByTestId('cars'); 271 | const rrsLabel = wrapper.getByTestId('rrs-label_cars'); 272 | 273 | // Focus 274 | act(() => select.focus()); 275 | 276 | // Open panel 277 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 278 | 279 | // Choose Fiat by keypressing Down 3 times 280 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 281 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 282 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 283 | 284 | // Change mind about selecting this option by hitting Escape key 285 | fireEvent.keyDown(select, { key: 'Escape', keyCode: 27 }); 286 | 287 | // Should NOT have updated label with new selection 288 | expect(String(rrsLabel.textContent).trim()).toEqual('Subaru'); 289 | 290 | // Should not have called onSubmit 291 | expect(submitSpy).not.toHaveBeenCalled(); 292 | 293 | // TODO: FOCUS/BLUR NOT TESTED 294 | }); 295 | }); 296 | 297 | test('Multiselect - empty options should disable the select', () => { 298 | const submitSpy = jest.fn(); 299 | const wrapper = render(); 36 | 37 | const select = wrapper.getByTestId('cars'); 38 | const rrsOption9 = wrapper.getByTestId('rrs-option_cars_9'); 39 | 40 | // Focus 41 | act(() => select.focus()); 42 | 43 | // Hit down key 44 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 45 | 46 | // Select is open 47 | expect(select.classList.contains('rrs')).toEqual(true); 48 | expect(select.classList.contains('rrs--options-visible')).toEqual(true); 49 | 50 | // Zonda is selected 51 | expect(rrsOption9.classList.contains('rrs__option')).toEqual(true); 52 | expect(rrsOption9.classList.contains('rrs__option--selected')).toEqual(true); 53 | expect(rrsOption9.classList.contains('rrs__option--next-selection')).toEqual(true); 54 | }); 55 | 56 | test('hitting **UP** key should open the options panel and highlight **SELECTED ITEM**', () => { 57 | const wrapper = render(); 80 | 81 | const select = wrapper.getByTestId('cars'); 82 | const rrsOption9 = wrapper.getByTestId('rrs-option_cars_9'); 83 | 84 | // Focus 85 | act(() => select.focus()); 86 | 87 | // Hit space key 88 | fireEvent.keyDown(select, { key: 'Space', keyCode: 32 }); 89 | 90 | // Select is open 91 | expect(select.classList.contains('rrs')).toEqual(true); 92 | expect(select.classList.contains('rrs--options-visible')).toEqual(true); 93 | 94 | // Zonda is selected 95 | expect(rrsOption9.classList.contains('rrs__option')).toEqual(true); 96 | expect(rrsOption9.classList.contains('rrs__option--selected')).toEqual(true); 97 | expect(rrsOption9.classList.contains('rrs__option--next-selection')).toEqual(true); 98 | }); 99 | 100 | test('hitting **ENTER** key should submit the form', () => { 101 | const submitSpy = jest.fn(); 102 | const wrapper = render(); 118 | 119 | const select = wrapper.getByTestId('cars'); 120 | const rrsOption4 = wrapper.getByTestId('rrs-option_cars_4'); 121 | 122 | // Focus 123 | act(() => select.focus()); 124 | 125 | // Hit `f` and `i` key 126 | fireEvent.keyDown(select, { key: 'f', keyCode: 70 }); 127 | fireEvent.keyDown(select, { key: 'i', keyCode: 73 }); 128 | 129 | jest.runTimersToTime(250); 130 | 131 | // `fiat` option was found and highlighted 132 | expect(rrsOption4.classList.contains('rrs__option')).toEqual(true); 133 | expect(rrsOption4.classList.contains('rrs__option--next-selection')).toEqual(true); 134 | }); 135 | }); 136 | 137 | describe('When focused and open', () => { 138 | test('hitting **TAB** key should not blur the Select Input', () => { 139 | // TODO: FOCUS/BLUR NOT TESTED 140 | }); 141 | 142 | test('hitting **DOWN** key should decrement down the options panel - highlighting next potential selection (must be a **FOCUSSABLE ITEM**). When the bottom of the options list is reached, it cycles the next potential selection up to the top of the list. It cycles infinitely', () => { 143 | const wrapper = render(); 177 | 178 | const select = wrapper.getByTestId('cars'); 179 | const rrsOption4 = wrapper.getByTestId('rrs-option_cars_4'); 180 | 181 | // Focus 182 | act(() => select.focus()); 183 | 184 | // Open panel 185 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 }); 186 | 187 | // Start moving down the options... continue till cycled back to 4th option (0 index) 188 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 }); 189 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 }); 190 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 }); 191 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 }); 192 | 193 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 }); 194 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 }); 195 | 196 | // Expect `Fiat` to be highlighted 197 | expect(rrsOption4.classList.contains('rrs__option')).toEqual(true); 198 | expect(rrsOption4.classList.contains('rrs__option--next-selection')).toEqual(true); 199 | }); 200 | 201 | test('hitting **a-z or 0-9** keys in quick succession (250ms) should highlight first item that starts with key pressed character/s (must be a **FOCUSSABLE ITEM**)', () => { 202 | const wrapper = render(); 227 | 228 | const select = wrapper.getByTestId('cars'); 229 | const rrsLabel = wrapper.getByTestId('rrs-label_cars'); 230 | 231 | // Focus 232 | act(() => select.focus()); 233 | 234 | // Open panel 235 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 236 | 237 | // Choose Fiat by keypressing Down 238 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 239 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 240 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 }); 241 | 242 | // Select it 243 | fireEvent.keyDown(select, { key: 'Enter', keyCode: 13 }); 244 | 245 | // Should have updated label with new selection 246 | expect(rrsLabel.textContent).toEqual('Fiat'); 247 | 248 | // Should not have called onSubmit 249 | expect(submitSpy).not.toHaveBeenCalled(); 250 | 251 | // TODO: FOCUS/BLUR NOT TESTED 252 | }); 253 | 254 | test("hitting **ESC** key should close the options panel and keep the user's last selection, or the initial selection, but not blur the Select Input", () => { 255 | const submitSpy = jest.fn(); 256 | const wrapper = render( 257 | ); 290 | 291 | expect(wrapper.getByRole('button').getAttribute('aria-disabled')).toBe('true'); 292 | }); 293 | 294 | describe('Mouse/Touch Device', () => { 295 | test("**TAPPING** on a select will open it's options", () => {}); 296 | 297 | test("**CLICKING** on a select will open it's options", () => {}); 298 | 299 | test("**TAPPING** on an option will select it's value", () => {}); 300 | 301 | test("**CLICKING** on an option will select it's value", () => {}); 302 | 303 | test('**DRAGGING** on an options panel that is scrollable, will scroll the options panel', () => {}); 304 | 305 | test('**SCROLLING** on an options panel that is scrollable, will scroll the options panel', () => {}); 306 | }); 307 | }); 308 | -------------------------------------------------------------------------------- /src/__tests__/test.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, cleanup, fireEvent } from '@testing-library/react'; 3 | import { BASIC_OPTIONS } from '../__mocks__/options'; 4 | import { Select } from '../'; 5 | 6 | afterEach(cleanup); 7 | 8 | describe('SingleSelect', () => { 9 | test('MouseDown on an option will select it', () => { 10 | const wrapper = render( 29 | ); 30 | 31 | // Open options panel 32 | const select = wrapper.getByTestId('cars'); 33 | fireEvent.mouseDown(select); 34 | 35 | // Click some options 36 | const rrsOption8 = wrapper.getByTestId('rrs-option_cars_8'); 37 | const rrsOption9 = wrapper.getByTestId('rrs-option_cars_9'); 38 | const rrsOption5 = wrapper.getByTestId('rrs-option_cars_5'); 39 | 40 | fireEvent.mouseDown(rrsOption9); 41 | fireEvent.mouseDown(rrsOption8); 42 | fireEvent.mouseDown(rrsOption5); 43 | 44 | // Expect that the label updates with 3 options 45 | const labelText = wrapper.getByTestId('rrs-label_cars').textContent; 46 | expect(labelText && labelText.trim()).toEqual('Zonda+ 2'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/components/MultiSelect.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import singleline from 'singleline'; 3 | import { IOption, IOutputMultiSelect, IOutputMultiSelectOption } from '../types/'; 4 | import { MultiSelectOption } from './MultiSelectOption'; 5 | 6 | interface TProps { 7 | selectBoxRef: HTMLDivElement | null; 8 | caretIcon: React.ReactNode; 9 | customLabelText: React.ReactNode; 10 | disabled: boolean; 11 | isDragging: boolean; 12 | isOptionsPanelOpen: boolean; 13 | multiSelectSelectedIndexes: number[]; 14 | multiSelectSelectedOptions: IOutputMultiSelect; 15 | name: string; 16 | options: IOption[]; 17 | nextPotentialSelectionIndex: number; 18 | prefix: string; 19 | } 20 | 21 | export class MultiSelect extends React.Component { 22 | private optionsButton: React.RefObject; 23 | private optionsContainer: React.RefObject; 24 | 25 | constructor(props: TProps) { 26 | super(props); 27 | this.optionsButton = React.createRef(); 28 | this.optionsContainer = React.createRef(); 29 | } 30 | 31 | public componentDidUpdate(prevProps: TProps): void { 32 | /* 33 | Focus selectBox button if options panel has just closed, 34 | there has been an interaction or the value has changed 35 | */ 36 | const { isOptionsPanelOpen, selectBoxRef } = this.props; 37 | 38 | const optionsPanelJustClosed = !isOptionsPanelOpen && prevProps.isOptionsPanelOpen; 39 | 40 | if (optionsPanelJustClosed && selectBoxRef && selectBoxRef.contains(document.activeElement)) { 41 | // tslint:disable-next-line 42 | this.optionsButton.current && this.optionsButton.current.focus(); 43 | } 44 | } 45 | 46 | public getAriaLabel(): string { 47 | const { multiSelectSelectedOptions, prefix } = this.props; 48 | const selectedOptionsLength = multiSelectSelectedOptions.options.length; 49 | 50 | return singleline(` 51 | Checkbox group ${prefix ? `${prefix} ` : ''} has 52 | ${selectedOptionsLength} item${selectedOptionsLength === 1 ? '' : 's'} selected. 53 | Selected option${selectedOptionsLength === 1 ? '' : 's'} ${selectedOptionsLength === 1 ? 'is' : 'are'} 54 | ${multiSelectSelectedOptions.options 55 | .map((option: IOutputMultiSelectOption): string => option.text || '') 56 | .join(' and ')} 57 | `); 58 | } 59 | 60 | public render(): React.ReactNode { 61 | const { 62 | caretIcon, 63 | customLabelText, 64 | disabled, 65 | isOptionsPanelOpen, 66 | multiSelectSelectedIndexes, 67 | multiSelectSelectedOptions, 68 | name, 69 | options, 70 | nextPotentialSelectionIndex, 71 | prefix, 72 | } = this.props; 73 | 74 | let optHeaderLabel: string = ''; 75 | 76 | return ( 77 |
78 |
91 | {customLabelText && ( 92 |
93 | 99 | {customLabelText} 100 | 101 | {caretIcon && caretIcon} 102 |
103 | )} 104 | 105 | {!customLabelText && ( 106 |
107 | 113 | 114 | 115 | {`${prefix ? `${prefix} ` : ''} 116 | ${multiSelectSelectedOptions.options.length > 0 ? multiSelectSelectedOptions.options[0].text : ''}`} 117 | 118 | {multiSelectSelectedOptions.options.length > 1 && ( 119 | 120 | {`+ ${multiSelectSelectedOptions.options.length - 1}`} 121 | 122 | )} 123 | 124 | 125 | {caretIcon && caretIcon} 126 |
127 | )} 128 | 129 | {name && ( 130 | v.value)].join(',')} 135 | /> 136 | )} 137 |
138 | 139 | 166 |
167 | ); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/components/MultiSelectOption.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import singleline from 'singleline'; 3 | import { IOption } from '../types/'; 4 | 5 | interface TProps { 6 | name: string; 7 | index: number; 8 | isOptionsPanelOpen: boolean; 9 | nextPotentialSelectionIndex: number; 10 | optionsContainerRef: React.RefObject; 11 | optHeaderLabel: string; 12 | multiSelectSelectedIndexes: number[]; 13 | option: IOption; 14 | } 15 | 16 | export class MultiSelectOption extends React.Component { 17 | private optionRef: React.RefObject; 18 | private scrollOffset: number; 19 | 20 | constructor(props: TProps) { 21 | super(props); 22 | this.optionRef = React.createRef(); 23 | this.scrollOffset = 0; 24 | } 25 | 26 | public getScrollOffset(): number { 27 | const el = document.querySelector('.rrs__option--header'); 28 | return Math.ceil((el && el.getBoundingClientRect().height) || 0); 29 | } 30 | 31 | public componentDidUpdate(): void { 32 | const { index, isOptionsPanelOpen, nextPotentialSelectionIndex, optionsContainerRef, optHeaderLabel } = this.props; 33 | 34 | if (index === nextPotentialSelectionIndex && isOptionsPanelOpen) { 35 | if (this.optionRef.current && optionsContainerRef.current) { 36 | this.optionRef.current.focus(); 37 | 38 | if (optHeaderLabel !== '') { 39 | const scrollDiff = Math.ceil( 40 | this.optionRef.current.getBoundingClientRect().top - optionsContainerRef.current.getBoundingClientRect().top 41 | ); 42 | 43 | this.scrollOffset = this.scrollOffset || this.getScrollOffset(); 44 | 45 | if (scrollDiff < this.scrollOffset) { 46 | optionsContainerRef.current.scroll( 47 | 0, 48 | Math.floor(optionsContainerRef.current.scrollTop - this.scrollOffset) 49 | ); 50 | } 51 | } 52 | } 53 | } 54 | } 55 | 56 | public isDisabled(option: IOption): boolean { 57 | return Boolean(option.disabled || option.optHeader); 58 | } 59 | 60 | public render(): React.ReactNode { 61 | const { index, name, multiSelectSelectedIndexes, nextPotentialSelectionIndex, option, optHeaderLabel } = this.props; 62 | const isSelected = multiSelectSelectedIndexes.some((i: number) => i === index); 63 | 64 | return ( 65 |
  • 87 | {option.markup || option.text} 88 |
  • 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/components/SingleSelect.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import singleline from 'singleline'; 3 | import { IOption, IOutputSingleSelect } from '../types/'; 4 | import { SingleSelectOption } from './SingleSelectOption'; 5 | 6 | interface TProps { 7 | prefix: string; 8 | singleSelectSelectedOption: IOutputSingleSelect; 9 | name: string; 10 | caretIcon: React.ReactNode; 11 | singleSelectSelectedIndex: number; 12 | noSelectionLabel: string; 13 | isOptionsPanelOpen: boolean; 14 | nextPotentialSelectionIndex: number; 15 | selectBoxRef: HTMLDivElement | null; 16 | customLabelText: React.ReactNode; 17 | disabled: boolean; 18 | options: IOption[]; 19 | } 20 | 21 | export class SingleSelect extends React.Component { 22 | private optionsButton: React.RefObject; 23 | private optionsContainer: React.RefObject; 24 | 25 | constructor(props: TProps) { 26 | super(props); 27 | this.optionsButton = React.createRef(); 28 | this.optionsContainer = React.createRef(); 29 | } 30 | 31 | public componentDidUpdate(prevProps: TProps): void { 32 | /* 33 | Focus selectBox button if options panel has just closed, 34 | there has been an interaction, 35 | or isOptionsPanelOpen and nextPotentialSelectionIndex === -1 36 | */ 37 | const { isOptionsPanelOpen, nextPotentialSelectionIndex, selectBoxRef } = this.props; 38 | 39 | const optionsPanelJustClosed = !isOptionsPanelOpen && prevProps.isOptionsPanelOpen; 40 | 41 | if (this.optionsButton.current) { 42 | if (optionsPanelJustClosed && selectBoxRef && selectBoxRef.contains(document.activeElement)) { 43 | this.optionsButton.current.focus(); 44 | } 45 | 46 | if (isOptionsPanelOpen && nextPotentialSelectionIndex === -1) { 47 | this.optionsButton.current.focus(); 48 | } 49 | } 50 | } 51 | 52 | public getCustomLabel(): React.ReactNode { 53 | const { prefix, name, singleSelectSelectedOption, caretIcon, customLabelText } = this.props; 54 | 55 | return ( 56 |
    57 | 63 | {customLabelText} 64 | 65 | {caretIcon && caretIcon} 66 |
    67 | ); 68 | } 69 | 70 | public getDefaultLabel(): React.ReactNode { 71 | const { 72 | prefix, 73 | singleSelectSelectedOption, 74 | name, 75 | caretIcon, 76 | singleSelectSelectedIndex, 77 | noSelectionLabel, 78 | } = this.props; 79 | 80 | if (singleSelectSelectedIndex === -1) { 81 | return ( 82 |
    83 | 89 | {prefix && {prefix}} 90 | {noSelectionLabel} 91 | 92 | {caretIcon && caretIcon} 93 |
    94 | ); 95 | } 96 | 97 | return ( 98 |
    99 | 105 | {prefix && {prefix}} 106 | {singleSelectSelectedOption.text ? singleSelectSelectedOption.text :  } 107 | 108 | {caretIcon && caretIcon} 109 |
    110 | ); 111 | } 112 | 113 | public render(): React.ReactNode { 114 | const { 115 | customLabelText, 116 | disabled, 117 | isOptionsPanelOpen, 118 | name, 119 | nextPotentialSelectionIndex, 120 | options, 121 | singleSelectSelectedIndex, 122 | singleSelectSelectedOption, 123 | } = this.props; 124 | 125 | let optHeaderLabel: string = ''; 126 | 127 | return ( 128 |
    129 |
    142 | {customLabelText ? this.getCustomLabel() : this.getDefaultLabel()} 143 | 144 | {name && ( 145 | 151 | )} 152 |
    153 | 154 | 181 |
    182 | ); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/components/SingleSelectOption.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import singleline from 'singleline'; 3 | import { IOption } from '../types/'; 4 | 5 | interface TProps { 6 | index: number; 7 | name: string; 8 | isOptionsPanelOpen: boolean; 9 | optionsContainerRef: React.RefObject; 10 | nextPotentialSelectionIndex: number; 11 | option: IOption; 12 | singleSelectSelectedIndex: number; 13 | optHeaderLabel: string; 14 | } 15 | 16 | export class SingleSelectOption extends React.Component { 17 | private optionRef: React.RefObject; 18 | private scrollOffset: number; 19 | 20 | constructor(props: TProps) { 21 | super(props); 22 | this.optionRef = React.createRef(); 23 | this.scrollOffset = 0; 24 | } 25 | 26 | public getScrollOffset(): number { 27 | const el = document.querySelector('.rrs__option--header'); 28 | return Math.ceil((el && el.getBoundingClientRect().height) || 0); 29 | } 30 | 31 | public componentDidUpdate(): void { 32 | const { index, isOptionsPanelOpen, nextPotentialSelectionIndex, optionsContainerRef, optHeaderLabel } = this.props; 33 | 34 | if (index === nextPotentialSelectionIndex && isOptionsPanelOpen) { 35 | if (this.optionRef.current && optionsContainerRef.current) { 36 | this.optionRef.current.focus(); 37 | 38 | if (optHeaderLabel !== '') { 39 | const scrollDiff = Math.ceil( 40 | this.optionRef.current.getBoundingClientRect().top - optionsContainerRef.current.getBoundingClientRect().top 41 | ); 42 | 43 | this.scrollOffset = this.scrollOffset || this.getScrollOffset(); 44 | 45 | if (scrollDiff < this.scrollOffset) { 46 | optionsContainerRef.current.scroll( 47 | 0, 48 | Math.floor(optionsContainerRef.current.scrollTop - this.scrollOffset) 49 | ); 50 | } 51 | } 52 | } 53 | } 54 | } 55 | 56 | public isDisabled(option: IOption): boolean { 57 | return Boolean(option.disabled || option.optHeader); 58 | } 59 | 60 | public render(): React.ReactNode { 61 | const { index, name, nextPotentialSelectionIndex, option, singleSelectSelectedIndex, optHeaderLabel } = this.props; 62 | 63 | return ( 64 |
  • 84 | {option.markup || option.text} 85 |
  • 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/constants/actionTypes.ts: -------------------------------------------------------------------------------- 1 | export const INITIALISE = 'INITIALISE'; 2 | export const SET_IS_DRAGGING = 'SET_IS_DRAGGING'; 3 | export const SET_OPTIONS_PANEL_OPEN = 'SET_OPTIONS_PANEL_OPEN'; 4 | export const SET_OPTIONS_PANEL_CLOSED = 'SET_OPTIONS_PANEL_CLOSED'; 5 | export const SET_SINGLESELECT_OPTIONS = 'SET_SINGLESELECT_OPTIONS'; 6 | export const SET_MULTISELECT_OPTIONS = 'SET_MULTISELECT_OPTIONS'; 7 | export const SET_OPTIONS_PANEL_CLOSED_NO_SELECTION = 'SET_OPTIONS_PANEL_CLOSED_NO_SELECTION'; 8 | export const SET_OPTIONS_PANEL_CLOSED_ONBLUR = 'SET_OPTIONS_PANEL_CLOSED_ONBLUR'; 9 | export const SET_NEXT_SELECTED_INDEX = 'SET_NEXT_SELECTED_INDEX'; 10 | export const SET_NEXT_SELECTED_INDEX_ALPHA_NUMERIC = 'SET_NEXT_SELECTED_INDEX_ALPHA_NUMERIC'; 11 | -------------------------------------------------------------------------------- /src/constants/keyCodes.ts: -------------------------------------------------------------------------------- 1 | export const keyCodes = { 2 | TAB: 9, 3 | ENTER: 13, 4 | SPACE: 32, 5 | ESCAPE: 27, 6 | UP: 38, 7 | DOWN: 40, 8 | }; 9 | -------------------------------------------------------------------------------- /src/docs/api.md: -------------------------------------------------------------------------------- 1 | ```jsx noeditor 2 | const API = () => ( 3 |
    4 |

    5 | SingleSelect mode 6 | * And MultiSelect mode - some ammendments below 7 |

    8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 23 | 24 | 25 | 26 | 27 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 125 | 126 | 127 | 128 | 129 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 178 | 179 | 180 | 181 | 182 | 198 | 199 | 200 | 201 | 202 | 237 | 238 | 239 | 240 | 241 | 254 | 255 | 256 | 257 | 258 | 264 | 265 | 266 |
    PropTypeDescription
    name (required)String 19 | A unique name to associate a select with it's selected option value/s 20 |
    21 | (also used on form submit) 22 |
    options (required)Array of objects 28 |

    Array of shape:

    29 | 30 | 31 |
     32 |                 {`{
     33 |     text: "Fiat",
     34 |     value: "fiat",
     35 |     markup: Fiat,
     36 |     disabled: true
     37 | }`}
     38 |               
    39 |
    40 |

    or

    41 | 42 |
     43 |                 {`{
     44 |     text: "Cars",
     45 |     optHeader: true
     46 | }`}
     47 |               
    48 |
    49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 |
    paramtyperequireddescription
    textStringyesdisplay value for the select and the default for the option label
    valueStringyesvalue that is submitted
    markupReactNode JSX markup used as the option label. Allows for the use of badges and icons...
    optHeaderBoolean  81 | Will display an option header when present. Use with a text property 82 |
    disabledBoolean disable option - option cannot be selected and is greyed
    92 | 93 |
    94 | 95 |

    96 | Note: text is used as the option label when markup is not present 97 |

    98 |
    onSubmitFunctionA function that submits your form
    onChangeFunction 109 |

    Listen for changes on selected option change

    110 |

    returns:

    111 | 112 |
    113 |                 {`{
    114 |     altered: boolean,
    115 |     value: option.value,
    116 |     text: option.text,
    117 |     name: The name prop you gave RRS
    118 | }`}
    119 |               
    120 |
    121 |

    122 | Note: altered signifies whether a select has been changed from it's original value. 123 |

    124 |
    onBlurFunction 130 |

    Listen for blur when select loses focus

    131 | 132 | returns: 133 |
    134 |
    135 |                 {`{
    136 |     altered: boolean,
    137 |     value: option.value,
    138 |     text: option.text,
    139 |     name: The name prop you gave RRS
    140 | }`}
    141 |               
    142 |
    143 |

    144 | Note: altered signifies whether a select has been changed from it's original value. 145 |

    146 |
    caretIconReactNodeAdd a dropdown icon by using JSX markup
    selectedValueString 157 | Pre-select an option with this value - should match an existing option.value, or if omitted the 158 | first item will be selected 159 |
    prefixStringPrefix for the select label
    disabledBooleanDisables the select control
    noSelectionLabelstring 175 | A custom label to be used when nothing is selected. When used, the first option is not automatically 176 | selected 177 |
    customLabelRendererFunction 183 |

    Allows you to format your own custom select label.

    184 |

    185 | The customLabelRenderer function returns an array option objects. To use this feature, you need to 186 | construct and return some JSX using the below param 187 |

    188 | 189 |
    190 |                 {`{
    191 |     value: option.value,
    192 |     text: option.text,
    193 |     name: The name prop you gave RRS
    194 | }`}
    195 |               
    196 |
    197 |
    onListenFunction 203 |

    Allows you to hook into changes in RRS

    204 |

    The onListen function returns the following:

    205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 |
    paramtypedescription
    isOpenBooleanWhether the options panel is currently open or closed
    namestringThe name prop you passed into the ReactResponsiveSelect component
    actionTypeStringThe internal action type that was fired within RRS
    229 | 230 |
    231 | 232 |

    233 | Handy for those situations where you need to change something potentially outside of your control, e.g. 234 | setting a class on {''} when the options panel opens to inhibit body scrolling. 235 |

    236 |
    onSelectFunction 242 | 243 | The onSelect function returns the following: 244 |
    245 |
    246 |                 {`{
    247 |     value: option.value,
    248 |     text: option.text,
    249 |     name: The name prop you gave RRS
    250 | }`}
    251 |               
    252 |
    253 |
    modalCloseButtonReactNode 259 |

    260 | Add a close button for when the the mobile view shows the selection modal. You'll essentially be clicking 261 | the background so this is purely visual. 262 |

    263 |
    267 | 268 |

    269 | MultiSelect mode 270 | * Same as SingleSelect mode, but with the following amendments 271 |

    272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 292 | 293 | 294 | 295 | 296 | 317 | 318 | 319 | 320 | 321 | 341 | 342 | 343 | 344 | 345 | 358 | 359 | 360 | 361 | 362 | 374 | 375 | 376 |
    multiselectBooleanMakes the select control handle multiple selections
    selectedValuesArray of String values 284 |

    285 | Pre-select several options with this value - should match against an existing option.value, 286 | or if omitted, the first item will be selected. 287 |

    288 | e.g. 289 | selectedValues={'{['}'mazda', 'ford'{']}'} 290 | 291 |
    onChangeFunction 297 |

    Listen for changes in selection

    298 | 299 | returns: 300 |
    301 |
    302 |                 {`{
    303 |     altered: boolean,
    304 |     options: [{
    305 |         text: option.text,
    306 |         value: option.value,
    307 |         name: The name prop you gave RRS
    308 |     ]
    309 | }`}
    310 |               
    311 |
    312 |
    313 |

    314 | Note: altered signifies whether a select has been changed from it's original value. 315 |

    316 |
    onBlurFunction 322 |

    Listen for blur when select loses focus

    323 | 324 | returns: 325 |
    326 |
    327 |                 {`{
    328 |     altered: boolean,
    329 |     options: [{
    330 |         value: option.value,
    331 |         text: option.text,
    332 |         name: The name prop you gave RRS
    333 |     }]
    334 | }`}
    335 |               
    336 |
    337 |

    338 | Note: altered signifies whether a select has been changed from it's original value. 339 |

    340 |
    onSelectFunction 346 | 347 | The onSelect function returns the following: 348 |
    349 |
    350 |                 {`{
    351 |     value: option.value,
    352 |     text: option.text,
    353 |     name: The name prop you gave RRS
    354 | }`}
    355 |               
    356 |
    357 |
    onDeselectFunction 363 |

    The onDeselect function returns the following:

    364 | 365 |
    366 |                 {`{
    367 |     value: option.value,
    368 |     text: option.text,
    369 |     name: The name prop you gave RRS
    370 | }`}
    371 |               
    372 |
    373 |
    377 |
    378 | ); 379 | 380 | ; 381 | ``` 382 | -------------------------------------------------------------------------------- /src/docs/home.md: -------------------------------------------------------------------------------- 1 | ```jsx noeditor 2 | import { Select, CaretIcon, MultiSelectOptionMarkup, CheckboxIcon } from '../react-responsive-select'; // 'react-responsive-select' 3 | 4 | const Badge = ({ text }) => ( 5 | 6 | 9 | {text} 10 | 11 | ); 12 | 13 | <> 14 |
    15 |
    16 |

    17 | A customisable, touchable, React select / multi-select form component. 18 |
    19 | Built with keyboard and screen reader accessibility in mind. 20 |

    21 |
    22 |
    23 |

    FEATURES

    24 |
      25 |
    • 26 | 27 | 28 | 29 |

      Single and Multi select modes

      30 |
    • 31 |
    • 32 | 33 | 34 | 35 |

      Accessible WAI ARIA compliance

      36 |
    • 37 |
    • 38 | 39 | 40 | 41 |

      Touch friendly

      42 |
    • 43 |
    • 44 | 45 | 46 | 47 |

      Keyboard friendly

      48 |
    • 49 |
    • 50 | 51 | 52 | 53 |

      Similar interaction experience across platforms

      54 |
    • 55 |
    • 56 | 57 | 58 | 59 |

      Easy to style

      60 |
    • 61 |
    62 |
    63 |

    DEMO

    64 |

    Single-select & multi-select modes

    65 |
    66 |
    67 | Any, 95 | }, 96 | { 97 | value: 'bmw', 98 | text: 'BMW', 99 | markup: , 100 | }, 101 | { 102 | value: 'fiat', 103 | text: 'Fiat', 104 | markup: , 105 | }, 106 | { 107 | value: 'subaru', 108 | text: 'Subaru', 109 | markup: , 110 | }, 111 | { 112 | value: 'tesla', 113 | text: 'Tesla', 114 | markup: , 115 | }, 116 | ]} 117 | caretIcon={} 118 | prefix="Car2: " 119 | selectedValue="tesla" 120 | onSubmit={() => console.log('onSubmit')} 121 | onChange={newValue => console.log('onChange', newValue)} 122 | /> 123 |
    Single-select custom options
    124 |
    125 |
    126 | console.log('onDeselect', deselectedOption)} 169 | onSelect={selectedOption => console.log('onSelect', selectedOption)} 170 | name="carType4" 171 | options={[ 172 | // (Required) an array of options - see above const options 173 | { 174 | text: 'Any', 175 | value: 'null', 176 | markup: , 177 | }, 178 | { 179 | text: 'AMC', 180 | value: 'amc', 181 | markup: , 182 | }, 183 | { 184 | text: 'BMW', 185 | value: 'bmw', 186 | markup: , 187 | }, 188 | { 189 | text: 'Delorean', 190 | value: 'delorean', 191 | markup: , 192 | }, 193 | { 194 | text: 'Fiat', 195 | value: 'fiat', 196 | markup: , 197 | }, 198 | { 199 | text: 'Ford', 200 | value: 'ford', 201 | markup: , 202 | }, 203 | { 204 | text: 'Mazda', 205 | value: 'mazda', 206 | markup: , 207 | }, 208 | { 209 | text: 'Oldsmobile', 210 | value: 'oldsmobile', 211 | markup: , 212 | }, 213 | { 214 | text: 'Subaru', 215 | value: 'subaru', 216 | markup: , 217 | }, 218 | { 219 | text: 'Tesla', 220 | value: 'tesla', 221 | markup: , 222 | }, 223 | { 224 | text: 'Toyota', 225 | value: 'toyota', 226 | markup: , 227 | }, 228 | ]} 229 | caretIcon={} 230 | prefix="Car4: " 231 | onChange={newValue => console.log('onChange', newValue)} 232 | onSubmit={() => console.log('onSubmit')} 233 | /> 234 |
    Multi-select
    235 |
    236 |
    237 | 238 |
    239 |

    VERSION 7+ CHANGE

    240 |

    241 | From version 7.0.0 on, you will need to use a "key" prop to update react-responsive-select's internal 242 | state. 243 |

    244 |

    245 | More on the reason for the change here: 246 |
    247 | 252 | https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#recommendation-fully-uncontrolled-component-with-a-key 253 | 254 |

    255 |

    256 | There are some examples in the Recipes section:{' '} 257 | /#/Recipes/Controlled example 1 258 |

    259 |
    260 | 261 |
    262 |

    LINKS

    263 | 277 |
    278 |
    279 | ; 280 | ``` 281 | -------------------------------------------------------------------------------- /src/docs/multiselect/basic.md: -------------------------------------------------------------------------------- 1 | ```jsx 2 | import { Select, CaretIcon, MultiSelectOptionMarkup, ModalCloseButton } from '../../react-responsive-select'; // 'react-responsive-select' 3 | 4 |
    5 | } 12 | onChange={newValue => console.log(newValue)} 13 | onSubmit={() => console.log('onSubmit')} 14 | options={[ 15 | { text: 'Cheap', optHeader: true, value: '' }, 16 | { 17 | value: 'alfa-romeo', 18 | text: 'Alfa Romeo', 19 | markup: , 20 | }, 21 | { 22 | value: 'fiat', 23 | text: 'Fiat', 24 | markup: , 25 | }, 26 | { 27 | value: 'subaru', 28 | text: 'Subaru', 29 | markup: , 30 | }, 31 | { 32 | value: 'suzuki', 33 | text: 'Suzuki', 34 | markup: , 35 | }, 36 | { text: 'Expensive', value: null, optHeader: true }, 37 | { 38 | value: 'bmw', 39 | text: 'BMW', 40 | markup: , 41 | }, 42 | { 43 | value: 'ferrari', 44 | text: 'Ferrari', 45 | markup: , 46 | }, 47 | { 48 | value: 'mercedes', 49 | text: 'Mercedes', 50 | markup: , 51 | }, 52 | { 53 | value: 'tesla', 54 | text: 'Tesla', 55 | markup: , 56 | }, 57 | { 58 | value: 'volvo', 59 | text: 'Volvo', 60 | markup: , 61 | }, 62 | { 63 | value: 'zonda', 64 | text: 'Zonda', 65 | markup: , 66 | }, 67 | ]} 68 | /> 69 | ; 70 | ``` 71 | -------------------------------------------------------------------------------- /src/docs/multiselect/noSelectionLabel.md: -------------------------------------------------------------------------------- 1 | Use `noSelectionLabel` to set the default text. 2 | 3 | By default the first option is selected when `noSelectionLabel` is not present. 4 | 5 | ```jsx 6 | import { Select, CaretIcon, MultiSelectOptionMarkup } from '../../react-responsive-select'; // 'react-responsive-select' 7 | 8 |
    9 | } 12 | onSubmit={() => console.log('Handle form submit here')} 13 | onChange={newValue => console.log(newValue)} 14 | options={[ 15 | { 16 | value: 'any', 17 | text: 'Any', 18 | markup: , 19 | }, 20 | { 21 | text: 'Cheap', 22 | optHeader: true, 23 | value: null, 24 | }, 25 | { 26 | value: 'citroen', 27 | text: 'Citroen', 28 | markup: , 29 | }, 30 | { 31 | value: 'fiat', 32 | text: 'Fiat', 33 | markup: , 34 | }, 35 | { 36 | value: 'subaru', 37 | text: 'Subaru', 38 | markup: , 39 | }, 40 | { 41 | value: 'suzuki', 42 | text: 'Suzuki', 43 | markup: , 44 | }, 45 | { 46 | text: 'Expensive', 47 | value: null, 48 | optHeader: true, 49 | }, 50 | { 51 | value: 'bmw', 52 | text: 'BMW', 53 | markup: , 54 | }, 55 | { 56 | value: 'ferrari', 57 | text: 'Ferrari', 58 | markup: , 59 | }, 60 | { 61 | value: 'mercedes', 62 | text: 'Mercedes', 63 | markup: , 64 | }, 65 | { 66 | value: 'tesla', 67 | text: 'Tesla', 68 | markup: , 69 | }, 70 | { 71 | value: 'volvo', 72 | text: 'Volvo', 73 | markup: , 74 | }, 75 | { 76 | value: 'zonda', 77 | text: 'Zonda', 78 | markup: , 79 | }, 80 | ]} 81 | /> 82 | ; 83 | ``` 84 | -------------------------------------------------------------------------------- /src/docs/recipes/ControlledExample2App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { IOption } from '../../react-responsive-select'; // 'react-responsive-select' 3 | import { Form } from './ControlledExample2Form'; 4 | import { BRANDS, COLOURS, MODELS, TColoursOption, TModelsOption } from './ControlledExample2MockProps'; 5 | 6 | interface IState { 7 | selectedBrand: string; 8 | selectedModel: string; 9 | selectedColour: string; 10 | brands: IOption[]; 11 | models: IOption[]; 12 | colours: IOption[]; 13 | functions: { 14 | handleChangeBrand: (newValue: IOption) => void; 15 | handleChangeModel: (newValue: IOption) => void; 16 | handleChangeColour: (newValue: IOption) => void; 17 | handleSubmit: (event: any) => void; 18 | }; 19 | } 20 | 21 | export class ControlledExample2App extends React.Component<{}, IState> { 22 | constructor(props: {}) { 23 | super(props); 24 | this.state = { 25 | selectedBrand: 'null', 26 | selectedModel: 'null', 27 | selectedColour: 'null', 28 | brands: BRANDS, 29 | models: MODELS, 30 | colours: COLOURS, 31 | functions: { 32 | handleChangeBrand: this.handleChangeBrand, 33 | handleChangeModel: this.handleChangeModel, 34 | handleChangeColour: this.handleChangeColour, 35 | handleSubmit: this.handleSubmit, 36 | }, 37 | }; 38 | } 39 | 40 | public handleChangeBrand = (newValue: IOption): void => { 41 | const models = MODELS.filter( 42 | (option: TModelsOption & { brand: string }) => option.brand === newValue.value || option.value === 'null' 43 | ); 44 | 45 | const colours = COLOURS.filter( 46 | (option: TColoursOption) => 47 | option.brands.some((brand: string) => brand === newValue.value) || option.value === 'null' 48 | ); 49 | 50 | this.setState({ 51 | selectedBrand: newValue.value || '', 52 | selectedModel: 'null', 53 | selectedColour: 'null', 54 | models, 55 | colours, 56 | }); 57 | }; 58 | 59 | public handleChangeModel = (newValue: IOption): void => { 60 | const selectedBrand = MODELS.find((model: TModelsOption) => model.value === newValue.value)?.brand || ''; 61 | 62 | const colours = COLOURS.filter( 63 | (option: TColoursOption) => 64 | option.brands.some((brand: string) => brand === selectedBrand) || option.value === 'null' 65 | ); 66 | 67 | this.setState({ 68 | selectedModel: newValue.value || '', 69 | colours, 70 | }); 71 | }; 72 | 73 | public handleChangeColour = (newValue: IOption): void => { 74 | this.setState({ 75 | selectedColour: newValue.value || '', 76 | }); 77 | }; 78 | 79 | public handleSubmit = (event: any): void => { 80 | console.log('handleSubmit()', this.state, event); 81 | }; 82 | 83 | public render(): React.ReactElement { 84 | return
    ; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/docs/recipes/ControlledExample2Form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Select, IOption, IOutputSingleSelect, CaretIcon } from '../../react-responsive-select'; // 'react-responsive-select' 3 | 4 | interface IFormProps { 5 | brands: IOption[]; 6 | models: IOption[]; 7 | colours: IOption[]; 8 | selectedBrand: string; 9 | selectedModel: string; 10 | selectedColour: string; 11 | functions: { 12 | handleChangeBrand: (newValue: IOutputSingleSelect) => void; 13 | handleChangeModel: (newValue: IOutputSingleSelect) => void; 14 | handleChangeColour: (newValue: IOutputSingleSelect) => void; 15 | handleSubmit: (event: any) => void; 16 | }; 17 | } 18 | 19 | export const Form = ({ 20 | brands, 21 | models, 22 | colours, 23 | selectedBrand, 24 | selectedModel, 25 | selectedColour, 26 | functions: { handleChangeBrand, handleChangeModel, handleChangeColour, handleSubmit }, 27 | }: IFormProps): React.ReactElement => ( 28 | 29 |
    30 | } 48 | prefix="Model: " 49 | selectedValue={selectedModel} 50 | onChange={handleChangeModel} 51 | onSubmit={handleSubmit} 52 | /> 53 |
    54 |
    55 | } 56 | /> 57 |
    58 |
    59 | 60 | } 100 | disabled={formikProps.isSubmitting} 101 | onSubmit={formikProps.handleSubmit} 102 | options={options} 103 | onChange={({ value, name }) => { 104 | formikProps.handleChange({ target: { value, name } }); 105 | }} 106 | onBlur={({ value, name }) => { 107 | formikProps.handleBlur({ target: { value, name } }); 108 | }} 109 | {...otherProps} 110 | /> 111 | 112 | 113 | ); 114 | 115 | const FormikMultiSelect = ({ formikProps, name, options, ...otherProps }) => ( 116 | <> 117 | console.log('onSubmit')} 36 | caretIcon={} 37 | selectedValue="subaru" 38 | /> 39 | console.log('onSubmit')} 36 | caretIcon={} 37 | selectedValue="subaru" 38 | /> 39 | } 8 | options={[ 9 | { value: 'null', text: 'Any' }, 10 | { value: 'alfa-romeo', text: 'Alfa Romeo' }, 11 | { value: 'bmw', text: 'BMW' }, 12 | { value: 'fiat', text: 'Fiat' }, 13 | { value: 'subaru', text: 'Subaru' }, 14 | { value: 'suzuki', text: 'Suzuki' }, 15 | { value: 'tesla', text: 'Tesla' }, 16 | { value: 'volvo', text: 'Volvo' }, 17 | { value: 'zonda', text: 'Zonda' }, 18 | ]} 19 | caretIcon={} 20 | prefix="Car1: " 21 | selectedValue="subaru" 22 | onChange={newValue => console.log('onChange', newValue)} 23 | onSubmit={() => console.log('onSubmit')} 24 | /> 25 | ; 26 | ``` 27 | -------------------------------------------------------------------------------- /src/docs/singleselect/customLabelRenderer.md: -------------------------------------------------------------------------------- 1 | Allows you to format your own custom select label. 2 | 3 | The customLabelRenderer function returns an option object. To use this feature, you need to construct and return some JSX using the below param 4 | 5 | ```jsx static noeditor 6 | { 7 | value: option.value, 8 | text: option.text, 9 | name: The name prop you gave RRS 10 | } 11 | ``` 12 | 13 | ```jsx 14 | import { Select, CaretIcon } from '../../react-responsive-select'; // 'react-responsive-select' 15 | 16 | const Badge = ({ text }) => ( 17 | 18 | 21 | {text} 22 | 23 | ); 24 | 25 |
    26 | } 9 | prefix="Car1: " 10 | selectedValue="subaru" 11 | onSubmit={() => console.log('onSubmit')} 12 | onChange={newValue => console.log('onChange', newValue)} 13 | options={[ 14 | { value: 'null', text: 'Any' }, 15 | { value: 'alfa-romeo', text: 'Alfa Romeo' }, 16 | { value: 'bmw', text: 'BMW' }, 17 | { value: 'fiat', text: 'Fiat' }, 18 | { value: 'subaru', text: 'Subaru' }, 19 | { value: 'suzuki', text: 'Suzuki' }, 20 | { value: 'tesla', text: 'Tesla' }, 21 | { value: 'volvo', text: 'Volvo' }, 22 | { value: 'zonda', text: 'Zonda' }, 23 | ]} 24 | /> 25 |
    ; 26 | ``` 27 | -------------------------------------------------------------------------------- /src/docs/singleselect/noSelectionLabel.md: -------------------------------------------------------------------------------- 1 | Use `noSelectionLabel` to set the default text. 2 | 3 | By default the first option is selected when `noSelectionLabel` is not present. 4 | 5 | ```jsx 6 | import { Select, CaretIcon } from '../../react-responsive-select'; // 'react-responsive-select' 7 | 8 | const Badge = ({ text }) => ( 9 | 10 | 13 | {text} 14 | 15 | ); 16 | 17 |
    18 | } 30 | prefix="Vehicle: " 31 | noSelectionLabel="Please select" 32 | selectedValue="" 33 | onChange={newValue => console.log('onChange', newValue)} 34 | onSubmit={() => console.log('onSubmit')} 35 | /> 36 |
    ; 37 | ``` 38 | -------------------------------------------------------------------------------- /src/docs/singleselect/option-markup.md: -------------------------------------------------------------------------------- 1 | `option.markup` allows you to add JSX into your options. In the below example, there are badges - any JSX will do. 2 | 3 | ```jsx 4 | import { Select, CaretIcon } from '../../react-responsive-select'; // 'react-responsive-select' 5 | 6 | const Badge = ({ text }) => ( 7 | 8 | 11 | {text} 12 | 13 | ); 14 | 15 |
    16 |