├── .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 |
64 | );
65 | ```
66 |
67 | Example usage (Multi Select):
68 |
69 | ```jsx
70 | import React from 'react';
71 | import { Select, CaretIcon, MultiSelectOptionMarkup, ModalCloseButton } from 'react-responsive-select';
72 |
73 | // for default styles...
74 | import 'react-responsive-select/dist/react-responsive-select.css';
75 |
76 | const Form = () => (
77 |
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 |
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( );
34 |
35 | const select = wrapper.getByTestId('cars');
36 | const rrsOption9 = wrapper.getByTestId('rrs-option_cars_9');
37 |
38 | // Focus
39 | act(() => select.focus());
40 |
41 | // Hit down key
42 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
43 |
44 | // Select is open
45 | expect(select.classList.contains('rrs')).toEqual(true);
46 | expect(select.classList.contains('rrs--options-visible')).toEqual(true);
47 |
48 | // Zonda is selected
49 | expect(rrsOption9.classList.contains('rrs__option')).toEqual(true);
50 | expect(rrsOption9.classList.contains('rrs__option--selected')).toEqual(true);
51 | expect(rrsOption9.classList.contains('rrs__option--next-selection')).toEqual(true);
52 | });
53 |
54 | test('hitting **UP** key should open the options panel and highlight **SELECTED ITEM**', () => {
55 | 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( );
78 |
79 | const select = wrapper.getByTestId('cars');
80 | const rrsOption9 = wrapper.getByTestId('rrs-option_cars_9');
81 |
82 | // Focus
83 | act(() => select.focus());
84 |
85 | // Hit space key
86 | fireEvent.keyDown(select, { key: 'Space', keyCode: 32 });
87 |
88 | // Select is open
89 | expect(select.classList.contains('rrs')).toEqual(true);
90 | expect(select.classList.contains('rrs--options-visible')).toEqual(true);
91 |
92 | // Zonda is selected
93 | expect(rrsOption9.classList.contains('rrs__option')).toEqual(true);
94 | expect(rrsOption9.classList.contains('rrs__option--selected')).toEqual(true);
95 | expect(rrsOption9.classList.contains('rrs__option--next-selection')).toEqual(true);
96 | });
97 |
98 | test('hitting **ENTER** key should submit the form', () => {
99 | const submitSpy = jest.fn();
100 | const wrapper = render(
101 |
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( );
120 |
121 | const select = wrapper.getByTestId('cars');
122 | const rrsOption4 = wrapper.getByTestId('rrs-option_cars_4');
123 |
124 | // Focus
125 | act(() => select.focus());
126 |
127 | // Hit `f` and `i` key
128 | fireEvent.keyDown(select, { key: 'f', keyCode: 70 });
129 | fireEvent.keyDown(select, { key: 'i', keyCode: 73 });
130 |
131 | jest.runTimersToTime(250);
132 |
133 | // `fiat` option was found and highlighted
134 | expect(rrsOption4.classList.contains('rrs__option')).toEqual(true);
135 | expect(rrsOption4.classList.contains('rrs__option--next-selection')).toEqual(true);
136 | });
137 | });
138 |
139 | describe('When focused and open', () => {
140 | test('hitting **TAB** key should not blur the Select Input', () => {
141 | // TODO: FOCUS/BLUR NOT TESTED
142 | });
143 |
144 | 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', () => {
145 | 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( );
179 |
180 | const select = wrapper.getByTestId('cars');
181 | const rrsOption3 = wrapper.getByTestId('rrs-option_cars_3');
182 |
183 | // Focus
184 | act(() => select.focus());
185 |
186 | // Open panel
187 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 });
188 |
189 | // Start moving down the options... continue till cycled back to 4th option (0 index)
190 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 });
191 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 });
192 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 });
193 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 });
194 |
195 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 });
196 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 });
197 |
198 | // Expect `BMW` to be highlighted
199 | expect(rrsOption3.classList.contains('rrs__option')).toEqual(true);
200 | expect(rrsOption3.classList.contains('rrs__option--next-selection')).toEqual(true);
201 | });
202 |
203 | 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**)', () => {
204 | jest.useFakeTimers();
205 |
206 | 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( );
231 |
232 | const select = wrapper.getByTestId('cars');
233 | const rrsLabel = wrapper.getByTestId('rrs-label_cars');
234 |
235 | // Focus
236 | act(() => select.focus());
237 |
238 | // Open panel
239 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
240 |
241 | // Choose Fiat by keypressing Down
242 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
243 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
244 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
245 |
246 | // Select it
247 | fireEvent.keyDown(select, { key: 'Enter', keyCode: 13 });
248 |
249 | // Should have updated label with new selection
250 | expect(String(rrsLabel.textContent).trim()).toEqual('Fiat');
251 |
252 | // Should not have called onSubmit
253 | expect(submitSpy).not.toHaveBeenCalled();
254 |
255 | // TODO: FOCUS/BLUR NOT TESTED
256 | });
257 |
258 | 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", () => {
259 | const submitSpy = jest.fn();
260 | const wrapper = render(
261 |
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( );
300 |
301 | expect(wrapper.getByRole('button').getAttribute('aria-disabled')).toBe('true');
302 | });
303 |
304 | describe('Mouse/Touch Device', () => {
305 | test("**TAPPING** on a select will open it's options", () => {});
306 |
307 | test("**CLICKING** on a select will open it's options", () => {});
308 |
309 | test("**TAPPING** on an option will select it's value", () => {});
310 |
311 | test("**CLICKING** on an option will select it's value", () => {});
312 |
313 | test('**DRAGGING** on an options panel that is scrollable, will scroll the options panel', () => {});
314 |
315 | test('**SCROLLING** on an options panel that is scrollable, will scroll the options panel', () => {});
316 | });
317 | });
318 |
--------------------------------------------------------------------------------
/src/__tests__/keyboard-single-select.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render, act, cleanup, fireEvent } from '@testing-library/react';
3 | import { BASIC_OPTIONS } from '../__mocks__/options';
4 | import { Select } from '../';
5 |
6 | jest.useFakeTimers();
7 |
8 | /**
9 | * **SELECTED ITEM** is the item set with selectedValue or the first **FIRST AVAILABLE OPTION**.
10 | *
11 | * **FIRST AVAILABLE OPTION** is the first option in the options list that is selectable. E.g:
12 | * - Is not an option header.
13 | * - Is not disabled.
14 | *
15 | * **FOCUSSABLE ITEM** is an option in the options list that is foccussable. E.g:
16 | * - Is not an option header.
17 | * - Is not disabled
18 | */
19 |
20 | afterEach(cleanup);
21 |
22 | describe('Keyboard SingleSelect', () => {
23 | describe('When not focused', () => {
24 | test('Select Input receives focus when **TABBED** to', () => {
25 | // TODO: FOCUS/BLUR NOT TESTED
26 | });
27 | });
28 |
29 | describe('When focused and closed', () => {
30 | test('hitting **TAB** key should blur Select Input', () => {
31 | // TODO: FOCUS/BLUR NOT TESTED
32 | });
33 |
34 | test('hitting **DOWN** key should open the options panel and highlight **SELECTED ITEM**', () => {
35 | 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( );
58 |
59 | const select = wrapper.getByTestId('cars');
60 | const rrsOption9 = wrapper.getByTestId('rrs-option_cars_9');
61 |
62 | // Focus
63 | act(() => select.focus());
64 |
65 | // Hit up key
66 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 });
67 |
68 | // Select is open
69 | expect(select.classList.contains('rrs')).toEqual(true);
70 | expect(select.classList.contains('rrs--options-visible')).toEqual(true);
71 |
72 | // Zonda is selected
73 | expect(rrsOption9.classList.contains('rrs__option')).toEqual(true);
74 | expect(rrsOption9.classList.contains('rrs__option--selected')).toEqual(true);
75 | expect(rrsOption9.classList.contains('rrs__option--next-selection')).toEqual(true);
76 | });
77 |
78 | test('hitting **SPACE** key should open the options panel and highlight **SELECTED ITEM**', () => {
79 | 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( );
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 | 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( );
144 |
145 | const select = wrapper.getByTestId('cars');
146 | const rrsOption4 = wrapper.getByTestId('rrs-option_cars_4');
147 |
148 | // Focus
149 | act(() => select.focus());
150 |
151 | // Open panel
152 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
153 |
154 | // Start moving down the options... continue till cycled back to 4th option
155 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
156 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
157 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
158 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
159 |
160 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
161 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
162 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
163 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
164 |
165 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
166 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
167 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
168 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
169 |
170 | // Expect `Fiat` to be highlighted
171 | expect(rrsOption4.classList.contains('rrs__option')).toEqual(true);
172 | expect(rrsOption4.classList.contains('rrs__option--next-selection')).toEqual(true);
173 | });
174 |
175 | 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', () => {
176 | 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( );
203 |
204 | const select = wrapper.getByTestId('cars');
205 | const rrsOption4 = wrapper.getByTestId('rrs-option_cars_4');
206 |
207 | // Focus
208 | act(() => select.focus());
209 |
210 | // Open panel
211 | fireEvent.keyDown(select, { key: 'Up', keyCode: 38 });
212 |
213 | // Hit `f` and `i` key
214 | fireEvent.keyDown(select, { key: 'f', keyCode: 70 });
215 | fireEvent.keyDown(select, { key: 'i', keyCode: 73 });
216 |
217 | jest.runTimersToTime(250);
218 |
219 | // `fiat` option was found and highlighted
220 | expect(rrsOption4.classList.contains('rrs__option')).toEqual(true);
221 | expect(rrsOption4.classList.contains('rrs__option--next-selection')).toEqual(true);
222 | });
223 |
224 | test('hitting **ENTER** key should select the current highlighted option and close the options panel, but not blur the Select Input', () => {
225 | const submitSpy = jest.fn();
226 | 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 |
258 | );
259 |
260 | const select = wrapper.getByTestId('cars');
261 | const rrsLabel = wrapper.getByTestId('rrs-label_cars');
262 |
263 | // Focus
264 | act(() => select.focus());
265 |
266 | // Open panel
267 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
268 |
269 | // Choose Fiat by keypressing Down 3 times
270 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
271 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
272 | fireEvent.keyDown(select, { key: 'Down', keyCode: 40 });
273 |
274 | // Change mind about selecting this option by hitting Escape key
275 | fireEvent.keyDown(select, { key: 'Escape', keyCode: 27 });
276 |
277 | // Should NOT have updated label with new selection
278 | expect(rrsLabel.textContent).toEqual('Subaru');
279 |
280 | // Should not have called onSubmit
281 | expect(submitSpy).not.toHaveBeenCalled();
282 |
283 | // TODO: FOCUS/BLUR NOT TESTED
284 | });
285 | });
286 |
287 | test('Singleselect - empty options should disable the select', () => {
288 | const submitSpy = jest.fn();
289 | const wrapper = render( );
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( );
11 |
12 | // Open options panel
13 | const select = wrapper.getByTestId('cars');
14 | fireEvent.mouseDown(select);
15 |
16 | // Click option 8
17 | const rrsOption8 = wrapper.getByTestId('rrs-option_cars_8');
18 | fireEvent.mouseDown(rrsOption8);
19 |
20 | // Expect that the label updates with option 8's text property
21 | expect(wrapper.getByTestId('rrs-label_cars').textContent).toEqual('Volvo');
22 | });
23 | });
24 |
25 | describe('MultiSelect', () => {
26 | test('MouseDown on an option will add it to the selected options', () => {
27 | const wrapper = render(
28 |
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 | Prop
12 | Type
13 | Description
14 |
15 |
16 | name (required)
17 | String
18 |
19 | A unique name to associate a select with it's selected option value/s
20 |
21 | (also used on form submit)
22 |
23 |
24 |
25 | options (required)
26 | Array of objects
27 |
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 | param
54 | type
55 | required
56 | description
57 |
58 |
59 | text
60 | String
61 | yes
62 | display value for the select and the default for the option label
63 |
64 |
65 | value
66 | String
67 | yes
68 | value that is submitted
69 |
70 |
71 | markup
72 | ReactNode
73 |
74 | JSX markup used as the option label. Allows for the use of badges and icons...
75 |
76 |
77 | optHeader
78 | Boolean
79 |
80 |
81 | Will display an option header when present. Use with a text
property
82 |
83 |
84 |
85 | disabled
86 | Boolean
87 |
88 | disable option - option cannot be selected and is greyed
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | Note: text
is used as the option label when markup
is not present
97 |
98 |
99 |
100 |
101 | onSubmit
102 | Function
103 | A function that submits your form
104 |
105 |
106 | onChange
107 | Function
108 |
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 |
125 |
126 |
127 | onBlur
128 | Function
129 |
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 |
147 |
148 |
149 | caretIcon
150 | ReactNode
151 | Add a dropdown icon by using JSX markup
152 |
153 |
154 | selectedValue
155 | String
156 |
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 |
160 |
161 |
162 | prefix
163 | String
164 | Prefix for the select label
165 |
166 |
167 | disabled
168 | Boolean
169 | Disables the select control
170 |
171 |
172 | noSelectionLabel
173 | string
174 |
175 | A custom label to be used when nothing is selected. When used, the first option is not automatically
176 | selected
177 |
178 |
179 |
180 | customLabelRenderer
181 | Function
182 |
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 |
198 |
199 |
200 | onListen
201 | Function
202 |
203 | Allows you to hook into changes in RRS
204 | The onListen function returns the following:
205 |
206 |
207 |
208 | param
209 | type
210 | description
211 |
212 |
213 | isOpen
214 | Boolean
215 | Whether the options panel is currently open or closed
216 |
217 |
218 | name
219 | string
220 | The name prop you passed into the ReactResponsiveSelect component
221 |
222 |
223 | actionType
224 | String
225 | The internal action type that was fired within RRS
226 |
227 |
228 |
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 |
237 |
238 |
239 | onSelect
240 | Function
241 |
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 |
254 |
255 |
256 | modalCloseButton
257 | ReactNode
258 |
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 |
264 |
265 |
266 |
267 |
268 |
269 | MultiSelect mode
270 | * Same as SingleSelect mode, but with the following amendments
271 |
272 |
273 |
274 |
275 |
276 | multiselect
277 | Boolean
278 | Makes the select control handle multiple selections
279 |
280 |
281 | selectedValues
282 | Array of String values
283 |
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 |
292 |
293 |
294 | onChange
295 | Function
296 |
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 |
317 |
318 |
319 | onBlur
320 | Function
321 |
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 |
341 |
342 |
343 | onSelect
344 | Function
345 |
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 |
358 |
359 |
360 | onDeselect
361 | Function
362 |
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 |
374 |
375 |
376 |
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 |
7 | {text[0]}
8 |
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 |
}
81 | prefix="Car1: "
82 | onChange={newValue => console.log('onChange', newValue)}
83 | onSubmit={() => console.log('onSubmit')}
84 | />
85 |
Single-select basic
86 |
87 |
88 |
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('onSelect', selectedOption)}
129 | options={[
130 | {
131 | value: 'null',
132 | text: 'Any',
133 | markup: Any ,
134 | },
135 | {
136 | value: 'bmw',
137 | text: 'BMW',
138 | markup: ,
139 | },
140 | {
141 | value: 'fiat',
142 | text: 'Fiat',
143 | markup: ,
144 | },
145 | {
146 | value: 'subaru',
147 | text: 'Subaru',
148 | markup: ,
149 | },
150 | {
151 | value: 'tesla',
152 | text: 'Tesla',
153 | markup: ,
154 | },
155 | ]}
156 | customLabelRenderer={selectedOption => 🎉 You selected 👉{selectedOption.text} }
157 | caretIcon={ }
158 | prefix="Car3: "
159 | selectedValue="bmw"
160 | onSubmit={() => console.log('onSubmit')}
161 | onChange={newValue => console.log('onChange', newValue)}
162 | />
163 | Single-select custom label
164 |
165 |
166 |
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 |
260 |
261 |
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 | ;
67 | ```
68 |
--------------------------------------------------------------------------------
/src/docs/multiselect/disabled.md:
--------------------------------------------------------------------------------
1 | ```jsx
2 | import { Select, CaretIcon, MultiSelectOptionMarkup } from '../../react-responsive-select'; // 'react-responsive-select'
3 |
4 | ;
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 | ;
73 | ```
74 |
--------------------------------------------------------------------------------
/src/docs/multiselect/optHeader.md:
--------------------------------------------------------------------------------
1 | Use `optHeader` in an option to create a visual grouping of options
2 |
3 | ```jsx
4 | import { Select, CaretIcon, MultiSelectOptionMarkup } from '../../react-responsive-select'; // 'react-responsive-select'
5 |
6 | ;
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 |
68 | );
69 |
--------------------------------------------------------------------------------
/src/docs/recipes/ControlledExample2MockProps.ts:
--------------------------------------------------------------------------------
1 | import { IOption } from '../../react-responsive-select'; // 'react-responsive-select'
2 |
3 | export type TBrandsOption = IOption;
4 | export type TModelsOption = IOption & { brand: string };
5 | export type TColoursOption = IOption & { brands: string[] };
6 |
7 | export const BRANDS: TBrandsOption[] = [
8 | { value: 'null', text: 'Any' },
9 | { value: 'alfa-romeo', text: 'Alfa Romeo' },
10 | { value: 'bmw', text: 'BMW' },
11 | { value: 'fiat', text: 'Fiat' },
12 | { value: 'lexus', text: 'Lexus' },
13 | { value: 'subaru', text: 'Subaru' },
14 | ];
15 |
16 | export const MODELS: TModelsOption[] = [
17 | { value: 'null', text: 'Any', brand: 'null' },
18 | { value: '4c', text: '4C', brand: 'alfa-romeo' },
19 | { value: '8c', text: '8C', brand: 'alfa-romeo' },
20 | { value: 'giulietta', text: 'Giulietta', brand: 'alfa-romeo' },
21 | { value: '320i', text: '320i', brand: 'bmw' },
22 | { value: '328i', text: '328i', brand: 'bmw' },
23 | { value: '520i', text: '520i', brand: 'bmw' },
24 | { value: 'm5', text: 'M5', brand: 'bmw' },
25 | { value: 'uno', text: 'Uno', brand: 'fiat' },
26 | { value: '124', text: '124', brand: 'fiat' },
27 | { value: 'lx350', text: 'LX 350', brand: 'lexus' },
28 | { value: 'gs400', text: 'GS 400', brand: 'lexus' },
29 | { value: 'forester', text: 'Forester', brand: 'subaru' },
30 | { value: 'impreza', text: 'Impreza', brand: 'subaru' },
31 | ];
32 |
33 | export const COLOURS: TColoursOption[] = [
34 | { value: 'null', text: 'Any', brands: [] },
35 | { value: 'blue', text: 'Blue', brands: ['alfa-romeo', 'subaru'] },
36 | { value: 'red', text: 'Red', brands: ['alfa-romeo', 'fiat', 'subaru'] },
37 | {
38 | value: 'white',
39 | text: 'White',
40 | brands: ['alfa-romeo', 'bmw', 'fiat', 'lexus'],
41 | },
42 | { value: 'black', text: 'Black', brands: ['alfa-romeo', 'bmw', 'lexus'] },
43 | {
44 | value: 'grey',
45 | text: 'Grey',
46 | brands: ['alfa-romeo', 'bmw', 'fiat', 'lexus'],
47 | },
48 | { value: 'purple', text: 'Purple', brands: ['fiat', 'subaru'] },
49 | { value: 'pink', text: 'Pink', brands: ['fiat', 'subaru'] },
50 | { value: 'green', text: 'Green', brands: ['bmw', 'fiat', 'lexus'] },
51 | ];
52 |
--------------------------------------------------------------------------------
/src/docs/recipes/controlled-example-1.md:
--------------------------------------------------------------------------------
1 | Selecting something in the first select will reset the second select.
2 |
3 | ```jsx
4 | import { Select, CaretIcon } from '../../react-responsive-select'; // 'react-responsive-select'
5 |
6 | const DATA = [
7 | { year: 2000, quarters: [1, 2, 3, 4] },
8 | { year: 2001, quarters: [1, 2, 3] },
9 | { year: 2002, quarters: [2, 3] },
10 | { year: 2004, quarters: [1, 4] },
11 | { year: 2005, quarters: [1, 2, 3, 4] },
12 | ];
13 |
14 | const ControlledExample = () => {
15 | const filteredYears = DATA.map(item => ({
16 | text: `${item.year}`,
17 | value: `${item.year}`,
18 | }));
19 |
20 | const extractQuarters = quarters =>
21 | quarters.map(quarter => ({
22 | text: `${quarter}`,
23 | value: `${quarter}`,
24 | }));
25 |
26 | const [years, setYears] = React.useState(filteredYears);
27 |
28 | const [quarters, setQuarters] = React.useState(extractQuarters(DATA[0].quarters));
29 |
30 | const [selectedYear, setSelectedYear] = React.useState(DATA[0].year.toString());
31 |
32 | const [selectedQuarter, setSelectedQuarter] = React.useState(DATA[0].quarters[0].toString());
33 |
34 | const onChangeYear = newValue => {
35 | const selectedYearDataFragment = DATA.find(item => item.year.toString() === newValue.value);
36 |
37 | setSelectedYear(newValue.value);
38 | setSelectedQuarter(selectedYearDataFragment.quarters[0].toString());
39 | setQuarters(extractQuarters(selectedYearDataFragment.quarters));
40 | };
41 |
42 | const onChangeQuarter = newValue => {
43 | setSelectedQuarter(newValue.value);
44 | };
45 |
46 | return (
47 |
70 | );
71 | };
72 |
73 | ;
74 | ```
75 |
--------------------------------------------------------------------------------
/src/docs/recipes/controlled-example-2.md:
--------------------------------------------------------------------------------
1 | The 3 selects are linked.
2 |
3 | First select a brand, then select a Model, then select a colour
4 |
5 | ```jsx
6 | import { ControlledExample2App } from './ControlledExample2App';
7 |
8 | // You'll need to view the repo if you are interested in this one
9 | ;
10 | ```
11 |
--------------------------------------------------------------------------------
/src/docs/recipes/formik.md:
--------------------------------------------------------------------------------
1 | Using react-responsive-select with Formik - an example of how to convert this into a formik component in your local component library.
2 |
3 | Certainly not the only way to do this. If you have a different solution; please share in the github issues for others to benefit.
4 |
5 | ```jsx
6 | import { Select, CaretIcon, ErrorIcon, MultiSelectOptionMarkup } from '../../react-responsive-select'; // 'react-responsive-select'
7 | import { Formik } from 'formik';
8 | import * as Yup from 'yup';
9 |
10 | const initialValues = {
11 | car: 'lexus',
12 | bikes: ['bmw'],
13 | };
14 |
15 | const validationSchema = Yup.object().shape({
16 | car: Yup.mixed().notOneOf(['null'], 'Please select a car'),
17 | bikes: Yup.mixed().test({
18 | name: 'something-other-than-any-selected',
19 | message: 'Please select a bike',
20 | test: value => value.indexOf('null') === -1,
21 | }),
22 | });
23 |
24 | const FormikForm = () => (
25 | {
29 | setTimeout(() => {
30 | setSubmitting(false);
31 | alert(JSON.stringify(values, null, 2));
32 | }, 1000);
33 | }}
34 | >
35 | {formikProps => (
36 |
90 | )}
91 |
92 | );
93 |
94 | const FormikSingleSelect = ({ formikProps, name, options, ...otherProps }) => (
95 | <>
96 | }
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 | }
122 | disabled={formikProps.isSubmitting}
123 | onSubmit={formikProps.handleSubmit}
124 | options={options}
125 | onChange={({ altered, options }) => {
126 | formikProps.handleChange({ target: { value: options.map(option => option.value), name } });
127 | }}
128 | onBlur={({ altered, options }) => {
129 | formikProps.handleBlur({ target: { value: options.map(option => option.value), name } });
130 | }}
131 | {...otherProps}
132 | />
133 |
134 | >
135 | );
136 |
137 | const CustomFormikError = ({ formikProps, name }) =>
138 | formikProps.errors[name] && formikProps.touched[name] ? (
139 |
140 | {formikProps.errors[name]}
141 |
142 | ) : null;
143 |
144 | ;
145 | ```
146 |
--------------------------------------------------------------------------------
/src/docs/recipes/onListen.md:
--------------------------------------------------------------------------------
1 | Listening for RRS changes with the "onListen" prop. This example blocks body scolling on small screen when options are open
2 |
3 | ```jsx
4 | import { Select, CaretIcon, MultiSelectOptionMarkup } from '../../react-responsive-select'; // 'react-responsive-select'
5 |
6 | let prevIsOpenValue;
7 |
8 | function onListen(isOpen, name, actionType) {
9 | if (isOpen && prevIsOpenValue !== isOpen) {
10 | document.body.classList.add('no-scroll-y');
11 | prevIsOpenValue = isOpen;
12 | } else if (!isOpen && prevIsOpenValue !== isOpen) {
13 | document.body.classList.remove('no-scroll-y');
14 | prevIsOpenValue = isOpen;
15 | }
16 |
17 | console.log({ isOpen, name, actionType });
18 | }
19 |
20 | ;
54 | ```
55 |
--------------------------------------------------------------------------------
/src/docs/recipes/onSelectOnDeselect.md:
--------------------------------------------------------------------------------
1 | For a multi-select you can see what value changed via the function props:
2 |
3 | `onSelect` and `onDeselect`
4 |
5 | For a single-select you can see what value changed via the function prop:
6 |
7 | `onSelect`
8 |
9 | ```jsx
10 | import { Select, CaretIcon, MultiSelectOptionMarkup } from '../../react-responsive-select'; // 'react-responsive-select'
11 |
12 | function onSelect(selectedValue) {
13 | console.log({ selectedValue });
14 | }
15 |
16 | function onDeselect(deselectedValue) {
17 | console.log({ deselectedValue });
18 | }
19 |
20 | ;
55 | ```
56 |
--------------------------------------------------------------------------------
/src/docs/screen-reader-demo.md:
--------------------------------------------------------------------------------
1 | ```jsx noeditor
2 |
3 |
7 |
11 | Your browser does not support the video tag.
12 |
13 | ```
14 |
--------------------------------------------------------------------------------
/src/docs/singleselect/basic.md:
--------------------------------------------------------------------------------
1 | ```jsx
2 | import { Select, CaretIcon, ModalCloseButton } from '../../react-responsive-select'; // 'react-responsive-select'
3 |
4 | ;
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 |
19 | {text[0]}
20 |
21 | {text}
22 |
23 | );
24 |
25 | ;
63 | ```
64 |
--------------------------------------------------------------------------------
/src/docs/singleselect/disabled.md:
--------------------------------------------------------------------------------
1 | ```jsx
2 | import { Select, CaretIcon } from '../../react-responsive-select'; // 'react-responsive-select'
3 |
4 | ;
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 |
11 | {text[0]}
12 |
13 | {text}
14 |
15 | );
16 |
17 | ;
54 | ```
55 |
--------------------------------------------------------------------------------
/src/docs/singleselect/optHeader.md:
--------------------------------------------------------------------------------
1 | Use `optHeader` in an option to create a visual grouping of options
2 |
3 | ```jsx
4 | import { Select, CaretIcon } from '../../react-responsive-select'; // 'react-responsive-select'
5 |
6 | ;
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 |
9 | {text[0]}
10 |
11 | {text}
12 |
13 | );
14 |
15 | ;
52 | ```
53 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './react-responsive-select';
2 |
--------------------------------------------------------------------------------
/src/lib/containsClassName.ts:
--------------------------------------------------------------------------------
1 | export function containsClassName(element: HTMLElement, classNameStr: string): boolean {
2 | return (
3 | String(element.className)
4 | .split(' ')
5 | .indexOf(classNameStr) > -1
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/src/lib/eventHandlers/handleAlphaNumerical.ts:
--------------------------------------------------------------------------------
1 | import * as actionTypes from '../../constants/actionTypes';
2 | import { Select } from '../../react-responsive-select';
3 | import { IOption, IState } from '../../types/';
4 |
5 | interface IArgs {
6 | event: KeyboardEvent;
7 | state: IState;
8 | RRSClassRef: Select;
9 | }
10 |
11 | let stringMatch: string = '';
12 | let timeoutActive: boolean;
13 |
14 | /**
15 | * User types some keys in quick successsion whilst focused on a select - search for this combonation in their options
16 | */
17 | export function handleAlphaNumerical({ event, state, RRSClassRef }: IArgs): void {
18 | const { options, disabled } = state;
19 |
20 | if (disabled) return;
21 |
22 | // Accumulate users key presses
23 | stringMatch = stringMatch + event.key;
24 |
25 | if (!timeoutActive) {
26 | timeoutActive = true;
27 |
28 | // Eventually (after 250ms) check if the accumulation of their keypresses matches the text of an option
29 | setTimeout(() => {
30 | const foundIndexes: number[] = options.reduce((acc: number[], option: IOption, index: number) => {
31 | if (
32 | !option.optHeader &&
33 | !option.disabled &&
34 | option.text &&
35 | option.text.toLowerCase().indexOf(stringMatch) !== -1
36 | ) {
37 | acc.push(index);
38 | }
39 | return acc;
40 | }, []);
41 |
42 | if (foundIndexes.length > 0) {
43 | RRSClassRef.updateState({
44 | value: foundIndexes[0],
45 | type: actionTypes.SET_NEXT_SELECTED_INDEX_ALPHA_NUMERIC,
46 | });
47 | }
48 |
49 | // allow for the creation of a new search
50 | timeoutActive = false;
51 | stringMatch = '';
52 | }, 250);
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/lib/eventHandlers/handleBlur.ts:
--------------------------------------------------------------------------------
1 | import * as actionTypes from '../../constants/actionTypes';
2 | import { Select } from '../../react-responsive-select';
3 | import { IProps, IState } from '../../types/';
4 | import { multiSelectBroadcastChange, singleSelectBroadcastChange } from '../onChangeBroadcasters';
5 |
6 | interface TArgs {
7 | state: IState;
8 | RRSClassRef: Select;
9 | props: IProps;
10 | }
11 |
12 | export function handleBlur({ state, RRSClassRef, props }: TArgs): void {
13 | const { onBlur, multiselect } = props;
14 | const { isOptionsPanelOpen, disabled, altered, singleSelectSelectedOption, multiSelectSelectedOptions } = state;
15 |
16 | if (disabled) return;
17 |
18 | const isOutsideSelectBox = RRSClassRef.selectBox && !RRSClassRef.selectBox.contains(document.activeElement);
19 |
20 | /* Handle click outside of selectbox */
21 | if (isOptionsPanelOpen && isOutsideSelectBox) {
22 | RRSClassRef.updateState({
23 | type: actionTypes.SET_OPTIONS_PANEL_CLOSED_ONBLUR,
24 | });
25 | }
26 |
27 | if (isOutsideSelectBox && onBlur) {
28 | if (multiselect) {
29 | multiSelectBroadcastChange(multiSelectSelectedOptions.options, Boolean(altered), onBlur);
30 | } else {
31 | singleSelectBroadcastChange(singleSelectSelectedOption, Boolean(altered), onBlur);
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/lib/eventHandlers/handleClick.ts:
--------------------------------------------------------------------------------
1 | import * as actionTypes from '../../constants/actionTypes';
2 | import { Select } from '../../react-responsive-select';
3 | import { containsClassName } from '../containsClassName';
4 |
5 | import { IState, IProps } from '../../types/';
6 |
7 | interface TArgs {
8 | event: MouseEvent | KeyboardEvent;
9 | state: IState;
10 | RRSClassRef: Select;
11 | props: IProps;
12 | }
13 |
14 | export function handleClick({ event, state, RRSClassRef, props }: TArgs): void {
15 | const { multiselect, multiSelectSelectedOptions, isOptionsPanelOpen, isDragging, disabled, options } = state;
16 |
17 | if (disabled) return;
18 |
19 | if (isDragging === false) {
20 | /* Disallow natural event flow - don't allow blur to happen from button focus to selected option focus */
21 | event.preventDefault();
22 |
23 | if (event && containsClassName(event.target as HTMLElement, 'rrs__options')) {
24 | return;
25 | }
26 |
27 | const value = parseFloat((event.target as any).getAttribute('data-key'));
28 |
29 | if (options[value] && (options[value].disabled === true || options[value].optHeader === true)) {
30 | return;
31 | }
32 |
33 | /* Select option index, if user selected option */
34 | if (containsClassName(event.target as HTMLElement, 'rrs__option')) {
35 | if (multiselect) {
36 | const isExistingSelection = multiSelectSelectedOptions.options.some(
37 | option => options[value] && options[value].hasOwnProperty('value') && option.value === options[value].value
38 | );
39 |
40 | if (!isExistingSelection && props.onSelect) {
41 | props.onSelect(options[value]);
42 | } else if (isExistingSelection && props.onDeselect) {
43 | props.onDeselect(options[value]);
44 | }
45 | } else if (!multiselect && props.onSelect) {
46 | props.onSelect(options[value]);
47 | }
48 |
49 | RRSClassRef.updateState({
50 | type: multiselect ? actionTypes.SET_MULTISELECT_OPTIONS : actionTypes.SET_SINGLESELECT_OPTIONS,
51 | value,
52 | });
53 |
54 | return;
55 | }
56 |
57 | /*
58 | When the options panel is open, treat clicking the label/select button
59 | or the background overlay on small screen as a 'no action'
60 | */
61 | if (
62 | isOptionsPanelOpen &&
63 | // button on desktop (rrs__label) or overlay on small screen (rrs)
64 | (containsClassName(event.target as HTMLElement, 'rrs__label') ||
65 | containsClassName(event.target as HTMLElement, 'rrs'))
66 | ) {
67 | RRSClassRef.updateState(
68 | {
69 | type: actionTypes.SET_OPTIONS_PANEL_CLOSED_NO_SELECTION,
70 | },
71 | () => RRSClassRef.focusButton()
72 | );
73 |
74 | return;
75 | }
76 |
77 | /* Else user clicked close or open the options panel */
78 | RRSClassRef.updateState(
79 | {
80 | type: isOptionsPanelOpen ? actionTypes.SET_OPTIONS_PANEL_CLOSED : actionTypes.SET_OPTIONS_PANEL_OPEN,
81 | },
82 | (newState: IState) => {
83 | // After state update, check if focus should be moved to the button
84 | if (newState.isOptionsPanelOpen === false) {
85 | RRSClassRef.focusButton();
86 | }
87 | }
88 | );
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/lib/eventHandlers/handleEnterPressed.ts:
--------------------------------------------------------------------------------
1 | import * as actionTypes from '../../constants/actionTypes';
2 | import { Select } from '../../react-responsive-select';
3 | import { IProps, IState } from '../../types/';
4 |
5 | interface IArgs {
6 | event: KeyboardEvent;
7 | state: IState;
8 | props: IProps;
9 | RRSClassRef: Select;
10 | }
11 |
12 | export function handleEnterPressed({ event, state, props, RRSClassRef }: IArgs): void {
13 | const { disabled, isOptionsPanelOpen, multiselect, nextPotentialSelectionIndex, options } = state;
14 |
15 | if (disabled) return;
16 |
17 | const value = parseFloat((event.target as any).getAttribute('data-key'));
18 |
19 | if (options[value] && (options[value].disabled === true || options[value].optHeader === true)) {
20 | return;
21 | }
22 |
23 | if (multiselect) {
24 | const isExistingSelection = state.multiSelectSelectedOptions.options.some(
25 | option => options[value] && options[value].hasOwnProperty('value') && option.value === options[value].value
26 | );
27 |
28 | if (!isExistingSelection && props.onSelect) {
29 | props.onSelect(options[value]);
30 | } else if (isExistingSelection && props.onDeselect) {
31 | props.onDeselect(options[value]);
32 | }
33 |
34 | RRSClassRef.updateState({
35 | type: actionTypes.SET_MULTISELECT_OPTIONS,
36 | value: nextPotentialSelectionIndex,
37 | });
38 | } else {
39 | if (props.onSelect) {
40 | props.onSelect(options[value]);
41 | }
42 |
43 | RRSClassRef.updateState({
44 | type: actionTypes.SET_SINGLESELECT_OPTIONS,
45 | value: nextPotentialSelectionIndex,
46 | });
47 | }
48 |
49 | if (isOptionsPanelOpen) {
50 | event.stopPropagation(); // Do not submit form
51 | } else {
52 | // tslint:disable-next-line
53 | props.onSubmit && props.onSubmit(event); // Submit the form
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/lib/eventHandlers/handleKeyEvent.ts:
--------------------------------------------------------------------------------
1 | import * as actionTypes from '../../constants/actionTypes';
2 | import { keyCodes } from '../../constants/keyCodes';
3 | import { Select } from '../../react-responsive-select';
4 | import { IProps, IState } from '../../types/';
5 | import { preventDefaultForKeyCodes } from '../preventDefaultForKeyCodes';
6 | import { handleAlphaNumerical } from './handleAlphaNumerical';
7 | import { handleClick } from './handleClick';
8 | import { handleEnterPressed } from './handleEnterPressed';
9 | import { handleKeyUpOrDownPressed } from './handleKeyUpOrDownPressed';
10 |
11 | interface TArgs {
12 | event: KeyboardEvent;
13 | state: IState;
14 | props: IProps;
15 | RRSClassRef: Select;
16 | }
17 |
18 | export function handleKeyEvent({ event, state, props, RRSClassRef }: TArgs): void {
19 | const { multiselect, isOptionsPanelOpen, disabled } = state;
20 |
21 | if (disabled) return;
22 |
23 | preventDefaultForKeyCodes([keyCodes.ENTER, keyCodes.SPACE, keyCodes.ESCAPE, keyCodes.UP, keyCodes.DOWN], event);
24 |
25 | /* handle alpha-nemeric key press */
26 | if (/^[a-z0-9]+$/.test(event.key)) {
27 | handleAlphaNumerical({ event, RRSClassRef, state });
28 | }
29 |
30 | switch (event.keyCode) {
31 | case keyCodes.TAB:
32 | /* Don't shift focus when the panel is open (unless it's a Multiselect) */
33 | if (isOptionsPanelOpen) {
34 | event.preventDefault();
35 |
36 | /**
37 | * Multiselect does not close on selection. Focus button to blur and close options panel on TAB
38 | * TODO add a test for this
39 | */
40 | if (multiselect) {
41 | RRSClassRef.updateState({ type: actionTypes.SET_OPTIONS_PANEL_CLOSED }, () => RRSClassRef.focusButton());
42 | }
43 | }
44 | break;
45 |
46 | case keyCodes.ENTER:
47 | /* can close the panel when open and focussed
48 | * can submit the form when closed and focussed */
49 | handleEnterPressed({
50 | RRSClassRef,
51 | event,
52 | props,
53 | state,
54 | });
55 | break;
56 |
57 | case keyCodes.SPACE:
58 | /* close the panel and select option when open, or open the panel if closed */
59 | if (isOptionsPanelOpen) {
60 | handleClick({ event, state, RRSClassRef, props });
61 | } else {
62 | RRSClassRef.updateState({
63 | type: actionTypes.SET_OPTIONS_PANEL_OPEN,
64 | });
65 | }
66 | break;
67 |
68 | case keyCodes.ESCAPE:
69 | /* remove focus from the panel when focussed */
70 | RRSClassRef.updateState({ type: actionTypes.SET_OPTIONS_PANEL_CLOSED_NO_SELECTION }, () =>
71 | RRSClassRef.focusButton()
72 | );
73 | break;
74 |
75 | case keyCodes.UP:
76 | /* will open the options panel if closed
77 | * will not decrement selection if options panel closed
78 | * if panel open, will decrement up the options list */
79 | handleKeyUpOrDownPressed({
80 | RRSClassRef,
81 | state,
82 | type: 'DECREMENT',
83 | });
84 | break;
85 |
86 | case keyCodes.DOWN:
87 | /* will open the options panel if closed
88 | * will not increment selection if options panel closed
89 | * if panel open, will increment down the options list */
90 | handleKeyUpOrDownPressed({
91 | RRSClassRef,
92 | state,
93 | type: 'INCREMENT',
94 | });
95 | break;
96 |
97 | default:
98 | break;
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/lib/eventHandlers/handleKeyUpOrDownPressed.ts:
--------------------------------------------------------------------------------
1 | import * as actionTypes from '../../constants/actionTypes';
2 | import { Select } from '../../react-responsive-select';
3 | import { IState } from '../../types/';
4 | import { getNextIndex } from '../getNextIndex';
5 |
6 | interface TArgs {
7 | state: IState;
8 | type: 'INCREMENT' | 'DECREMENT';
9 | RRSClassRef: Select;
10 | }
11 |
12 | export function handleKeyUpOrDownPressed({ state, RRSClassRef, type }: TArgs): void {
13 | const { isOptionsPanelOpen, disabled } = state;
14 |
15 | if (disabled) return;
16 |
17 | RRSClassRef.updateState({
18 | type: actionTypes.SET_NEXT_SELECTED_INDEX,
19 | value: getNextIndex(type, state),
20 | });
21 |
22 | /* Open the options panel */
23 | if (isOptionsPanelOpen === false) {
24 | RRSClassRef.updateState({
25 | type: actionTypes.SET_OPTIONS_PANEL_OPEN,
26 | });
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/lib/eventHandlers/handleTouchMove.ts:
--------------------------------------------------------------------------------
1 | import * as actionTypes from '../../constants/actionTypes';
2 | import { Select } from '../../react-responsive-select';
3 | import { IState } from '../../types/';
4 |
5 | interface TArgs {
6 | state: IState;
7 | RRSClassRef: Select;
8 | }
9 |
10 | export function handleTouchMove({ state, RRSClassRef }: TArgs): void {
11 | /* if touchmove fired - User is dragging, this disables touchend/click */
12 | const { isDragging, disabled } = state;
13 |
14 | if (disabled) return;
15 |
16 | if (!isDragging) {
17 | RRSClassRef.updateState({
18 | type: actionTypes.SET_IS_DRAGGING,
19 | value: true,
20 | });
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/lib/eventHandlers/handleTouchStart.ts:
--------------------------------------------------------------------------------
1 | import * as actionTypes from '../../constants/actionTypes';
2 | import { Select } from '../../react-responsive-select';
3 | import { IState } from '../../types/';
4 |
5 | interface TArgs {
6 | state: IState;
7 | RRSClassRef: Select;
8 | }
9 |
10 | export function handleTouchStart({ state, RRSClassRef }: TArgs): void {
11 | const { disabled } = state;
12 |
13 | if (disabled) return;
14 |
15 | /* initially it's assumed that the user is not dragging */
16 | RRSClassRef.updateState({
17 | type: actionTypes.SET_IS_DRAGGING,
18 | value: false,
19 | });
20 | }
21 |
--------------------------------------------------------------------------------
/src/lib/eventHandlers/index.ts:
--------------------------------------------------------------------------------
1 | export { handleAlphaNumerical } from './handleAlphaNumerical';
2 | export { handleBlur } from './handleBlur';
3 | export { handleClick } from './handleClick';
4 | export { handleEnterPressed } from './handleEnterPressed';
5 | export { handleKeyEvent } from './handleKeyEvent';
6 | export { handleKeyUpOrDownPressed } from './handleKeyUpOrDownPressed';
7 | export { handleTouchMove } from './handleTouchMove';
8 | export { handleTouchStart } from './handleTouchStart';
9 |
--------------------------------------------------------------------------------
/src/lib/getCustomLabelText.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { IProps, IState } from '../types/';
3 |
4 | interface TArgs {
5 | state: IState;
6 | props: IProps;
7 | }
8 |
9 | export function getCustomLabelText({ state, props }: TArgs): React.ReactNode {
10 | const { multiselect, customLabelRenderer } = props;
11 | const { multiSelectSelectedOptions, singleSelectSelectedOption } = state;
12 |
13 | if (!customLabelRenderer) return false;
14 |
15 | if (multiselect) {
16 | return customLabelRenderer(multiSelectSelectedOptions);
17 | }
18 |
19 | return customLabelRenderer(singleSelectSelectedOption);
20 | }
21 |
--------------------------------------------------------------------------------
/src/lib/getNextIndex.ts:
--------------------------------------------------------------------------------
1 | import { IState } from '../types/';
2 | import { nextValidIndex } from './nextValidIndex';
3 |
4 | export function getNextIndex(mode: 'INCREMENT' | 'DECREMENT', state: IState): number {
5 | const { isOptionsPanelOpen, nextPotentialSelectionIndex, options } = state;
6 |
7 | switch (mode) {
8 | case 'INCREMENT':
9 | // Hold selection on current selected option when options panel first opens
10 | if (isOptionsPanelOpen === false) {
11 | return nextValidIndex(state, nextPotentialSelectionIndex, 'INCREMENT');
12 | }
13 |
14 | // User is at the end of the options so cycle back to start
15 | if (nextPotentialSelectionIndex === options.length - 1) {
16 | return nextValidIndex(state, 0, 'INCREMENT');
17 | }
18 |
19 | // Else increment
20 | return nextValidIndex(state, nextPotentialSelectionIndex + 1, 'INCREMENT');
21 |
22 | case 'DECREMENT':
23 | // Hold selection on current selected option when options panel first opens
24 | if (isOptionsPanelOpen === false) {
25 | return nextValidIndex(state, nextPotentialSelectionIndex, 'DECREMENT');
26 | }
27 |
28 | // User is at start of the options so cycle around to end
29 | if (nextPotentialSelectionIndex === 0) {
30 | return nextValidIndex(state, options.length - 1, 'DECREMENT');
31 | }
32 |
33 | // Else decrement
34 | return nextValidIndex(state, nextPotentialSelectionIndex - 1, 'DECREMENT');
35 |
36 | default:
37 | return nextValidIndex(state, 0, 'DECREMENT');
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/lib/isEqual.ts:
--------------------------------------------------------------------------------
1 | const skipCircularReference = () => {
2 | let cache: any[] = [];
3 | return (_key: string, value: any) => {
4 | if (typeof value === 'object' && value !== null) {
5 | // Circular reference found
6 | if (cache.indexOf(value) !== -1) return;
7 | cache.push(value);
8 | }
9 | // No circular reference found
10 | return value;
11 | };
12 | };
13 |
14 | export function isEqual(a: any, b: any): boolean {
15 | return JSON.stringify(a, skipCircularReference()) === JSON.stringify(b, skipCircularReference());
16 | }
17 |
--------------------------------------------------------------------------------
/src/lib/nextValidIndex.ts:
--------------------------------------------------------------------------------
1 | import { IOption, IState } from '../types/';
2 |
3 | export function nextValidIndex(
4 | state: IState,
5 | nextPotentialSelectionIndex: number,
6 | mode: 'INCREMENT' | 'DECREMENT' = 'INCREMENT'
7 | ): number {
8 | const { options } = state;
9 |
10 | const possibleOptionIndexes = options.reduce((acc: number[], option: IOption, index: number) => {
11 | if (!option.optHeader) acc.push(index);
12 | return acc;
13 | }, []);
14 |
15 | const indexNotFocusable = possibleOptionIndexes.indexOf(nextPotentialSelectionIndex) === -1;
16 |
17 | if (indexNotFocusable && mode === 'INCREMENT') {
18 | const nextSelectionPossible =
19 | options[nextPotentialSelectionIndex + 1] && !options[nextPotentialSelectionIndex + 1].optHeader;
20 |
21 | return nextSelectionPossible ? nextPotentialSelectionIndex + 1 : possibleOptionIndexes[0];
22 | }
23 |
24 | if (indexNotFocusable && mode === 'DECREMENT') {
25 | const nextSelectionPossible =
26 | options[nextPotentialSelectionIndex - 1] && !options[nextPotentialSelectionIndex - 1].optHeader;
27 |
28 | return nextSelectionPossible
29 | ? nextPotentialSelectionIndex - 1
30 | : possibleOptionIndexes[possibleOptionIndexes.length - 1];
31 | }
32 |
33 | return nextPotentialSelectionIndex;
34 | }
35 |
--------------------------------------------------------------------------------
/src/lib/onChangeBroadcasters/index.ts:
--------------------------------------------------------------------------------
1 | export { multiSelectBroadcastChange } from './multiSelectBroadcastChange';
2 | export { singleSelectBroadcastChange } from './singleSelectBroadcastChange';
3 |
--------------------------------------------------------------------------------
/src/lib/onChangeBroadcasters/multiSelectBroadcastChange.ts:
--------------------------------------------------------------------------------
1 | import { IOutputMultiSelect, IOutputMultiSelectOption } from '../../types/';
2 | import { isEqual } from '../../lib/isEqual';
3 |
4 | export function multiSelectBroadcastChange(
5 | currOptions: IOutputMultiSelectOption[],
6 | altered: boolean,
7 | fn?: (changes: IOutputMultiSelect) => void,
8 | prevOptions?: IOutputMultiSelectOption[]
9 | ): void {
10 | if (!fn) return;
11 |
12 | const shouldBroadcastChange = !prevOptions || !isEqual(prevOptions.values, currOptions.values);
13 |
14 | if (shouldBroadcastChange) {
15 | fn({
16 | options: currOptions.map((currOption: IOutputMultiSelectOption) => ({
17 | name: currOption.name || '',
18 | text: currOption.text || '',
19 | value: currOption.value || '',
20 | })),
21 | altered,
22 | });
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/lib/onChangeBroadcasters/singleSelectBroadcastChange.ts:
--------------------------------------------------------------------------------
1 | import { IOutputSingleSelect } from '../../types/';
2 | import { isEqual } from '../../lib/isEqual';
3 |
4 | export function singleSelectBroadcastChange(
5 | currValue: IOutputSingleSelect,
6 | altered?: boolean,
7 | fn?: (changes: IOutputSingleSelect) => void,
8 | prevValue?: IOutputSingleSelect
9 | ): void {
10 | if (!fn) return;
11 |
12 | const shouldBroadcastChange = !isEqual(prevValue?.value, currValue?.value);
13 |
14 | if (shouldBroadcastChange) {
15 | fn({
16 | name: currValue.name,
17 | text: currValue.text,
18 | value: currValue.value,
19 | altered,
20 | });
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/lib/preventDefaultForKeyCodes.ts:
--------------------------------------------------------------------------------
1 | export function preventDefaultForKeyCodes(keyCodes: number[], e: KeyboardEvent): void {
2 | keyCodes.forEach((keyCode: number) => {
3 | if (keyCode === e.keyCode) {
4 | e.preventDefault();
5 | }
6 | });
7 | }
8 |
--------------------------------------------------------------------------------
/src/react-responsive-select.css:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | React Responsive Select - Default styles
4 |
5 | */
6 |
7 | .rrs {
8 | font-family: sans-serif;
9 | position: relative;
10 | box-sizing: border-box;
11 | }
12 |
13 | .rrs *,
14 | .rrs *:before,
15 | .rrs *:after {
16 | box-sizing: border-box;
17 | }
18 |
19 | .rrs__button {
20 | color: #555;
21 | position: relative;
22 | cursor: pointer;
23 | line-height: 44px;
24 | background: #fff;
25 | border-radius: 4px;
26 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2);
27 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
28 | }
29 |
30 | .rrs__button--disabled {
31 | color: #999999;
32 | background: #f5f5f5;
33 | cursor: default;
34 | }
35 |
36 | .rrs__button:focus {
37 | outline: 0;
38 | }
39 |
40 | .rrs--options-visible .rrs__button {
41 | border-radius: 4px 4px 0 0;
42 | }
43 |
44 | .rrs__button + .rrs__options {
45 | list-style: none;
46 | padding: 0;
47 | margin: 0;
48 | background: #fff;
49 | position: absolute;
50 | z-index: 2;
51 | border: 1px solid #999;
52 | border-top: 1px solid #eee;
53 | border-radius: 0 0 4px 4px;
54 | top: 44px;
55 | left: 0;
56 | right: 0;
57 | height: 0;
58 | visibility: hidden;
59 | overflow: hidden;
60 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2);
61 | }
62 |
63 | .rrs--options-visible .rrs__options {
64 | height: auto;
65 | visibility: visible;
66 | overflow: auto;
67 | -webkit-overflow-scrolling: touch;
68 | max-height: 230px;
69 | }
70 |
71 | /*
72 |
73 | Mobile Close Icon
74 |
75 | */
76 |
77 | .mobile-close {
78 | display: none;
79 | }
80 |
81 | @keyframes fadeIn {
82 | from {
83 | opacity: 0;
84 | }
85 | to {
86 | opacity: 1;
87 | }
88 | }
89 |
90 | .rrs__option {
91 | cursor: pointer;
92 | padding: 0.75rem 1rem;
93 | margin: 0;
94 | }
95 |
96 | .rrs__option * {
97 | pointer-events: none;
98 | }
99 |
100 | .rrs__option:focus {
101 | outline: 1px solid #e0e0e0;
102 | }
103 |
104 | .rrs__option:hover {
105 | background: #f5f5f5;
106 | color: #0273b5;
107 | }
108 |
109 | .rrs__option:active {
110 | background: #e1f5fe;
111 | }
112 |
113 | .rrs__option.rrs__option--next-selection {
114 | background: #f1f8fb;
115 | color: #0273b5;
116 | }
117 |
118 | .rrs__option.rrs__option--selected {
119 | color: #0273b5;
120 | }
121 |
122 | .rrs__option.rrs__option--disabled {
123 | color: #999999;
124 | background: #f5f5f5;
125 | cursor: default;
126 | }
127 |
128 | .rrs__option.rrs__option--header {
129 | color: #666666;
130 | cursor: default;
131 | font-size: 0.7rem;
132 | font-weight: 700;
133 | text-transform: uppercase;
134 | background: #f5f5f5;
135 | position: sticky;
136 | top: 0;
137 | z-index: 1;
138 | }
139 |
140 | .rrs__label {
141 | padding: 0 2rem 0 1rem;
142 | display: inline-flex;
143 | height: 100%;
144 | width: 100%;
145 | max-width: 100%;
146 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
147 | font-size: inherit;
148 | background: transparent;
149 | border-radius: 4px;
150 | border: 1px solid rgba(0, 0, 0, 0);
151 | white-space: nowrap;
152 | text-overflow: ellipsis;
153 | overflow: hidden;
154 | }
155 |
156 | .rrs__label * {
157 | pointer-events: none;
158 | }
159 |
160 | .rrs--options-visible .rrs__label,
161 | .rrs__button:focus .rrs__label {
162 | outline: 0;
163 | border: 1px solid #999;
164 | }
165 |
166 | .rrs--options-visible .rrs__label {
167 | border-radius: 4px 4px 0 0;
168 | }
169 |
170 | .rrs--has-changed .rrs__label {
171 | color: #0273b5;
172 | }
173 |
174 | /*
175 |
176 | Multiselect overrides
177 |
178 | */
179 |
180 | .rrs__multiselect-label {
181 | display: inline-flex;
182 | max-width: 100%;
183 | line-height: 1;
184 | }
185 |
186 | .rrs__multiselect-label__text {
187 | overflow: hidden;
188 | white-space: nowrap;
189 | text-overflow: ellipsis;
190 | }
191 |
192 | .rrs__multiselect-label__badge {
193 | border: 1px solid #ccc;
194 | padding: 1px 6px;
195 | margin: 0 0 0 4px;
196 | border-radius: 8px;
197 | color: #666;
198 | font-size: 11px;
199 | vertical-align: middle;
200 | display: inline-block;
201 | }
202 |
203 | /*
204 |
205 | Checkbox
206 |
207 | */
208 |
209 | .rrs .checkbox {
210 | display: inline-block;
211 | position: relative;
212 | vertical-align: middle;
213 | width: 16px;
214 | height: 16px;
215 | top: -1px;
216 | border: 1px solid #ccc;
217 | margin: 2px 8px 2px 0;
218 | border-radius: 4px;
219 | }
220 |
221 | .rrs__option.rrs__option--selected .checkbox {
222 | border: 1px solid #0273b5;
223 | }
224 |
225 | .rrs .checkbox-icon {
226 | fill: transparent;
227 | position: absolute;
228 | left: 1px;
229 | top: 1px;
230 | }
231 |
232 | .rrs__option.rrs__option--selected .checkbox-icon {
233 | fill: #0273b5;
234 | }
235 |
236 | /*
237 |
238 | Caret Icon
239 |
240 | */
241 |
242 | .rrs .caret-icon {
243 | position: absolute;
244 | right: 1rem;
245 | top: 1.25rem;
246 | fill: #333;
247 | }
248 |
249 | .rrs--options-visible .caret-icon {
250 | transform: rotate(180deg);
251 | }
252 |
253 | /*
254 |
255 | Badge
256 |
257 | */
258 |
259 | .badge {
260 | background: #f1f1f1;
261 | padding: 0.25rem 1rem;
262 | margin: 2px 10px 2px 0;
263 | border-radius: 3px;
264 | }
265 |
266 | /*
267 |
268 | Open in a modal when smaller potentially touch screen
269 |
270 | */
271 |
272 | @media screen and (max-width: 768px) {
273 | .rrs {
274 | position: static;
275 | }
276 |
277 | .rrs.rrs--options-visible:after {
278 | content: '';
279 | cursor: pointer;
280 | position: fixed;
281 | animation: fadeIn 0.3s ease forwards;
282 | z-index: 1;
283 | left: 0;
284 | right: 0;
285 | top: 0;
286 | bottom: 0;
287 | background: rgba(0, 0, 0, 0.5);
288 | }
289 |
290 | .rrs--options-visible .rrs__options {
291 | max-height: initial;
292 | position: fixed;
293 | font-size: 1.25rem;
294 | width: auto;
295 | left: 1rem;
296 | right: 1rem;
297 | top: 15%;
298 | bottom: 1rem;
299 | border: 0;
300 | border-radius: 4px;
301 | }
302 |
303 | /*
304 |
305 | Mobile Close Icon
306 |
307 | */
308 |
309 | .mobile-close {
310 | display: block;
311 | cursor: pointer;
312 | line-height: 1;
313 | position: fixed;
314 | top: 1rem;
315 | right: 1rem;
316 | z-index: 1000;
317 | -webkit-transform: translateZ(0);
318 | -o-transform: translateZ(0);
319 | transform: translateZ(0);
320 | }
321 |
322 | .mobile-close__icon {
323 | fill: #fff;
324 | padding: 0.5rem;
325 | width: 2rem;
326 | height: 2rem;
327 | border-radius: 4px;
328 | }
329 | }
330 |
--------------------------------------------------------------------------------
/src/react-responsive-select.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import singleline from 'singleline';
3 | import * as actionTypes from './constants/actionTypes';
4 | import { handleBlur, handleClick, handleKeyEvent, handleTouchMove, handleTouchStart } from './lib/eventHandlers';
5 | import { getCustomLabelText } from './lib/getCustomLabelText';
6 | import { multiSelectBroadcastChange, singleSelectBroadcastChange } from './lib/onChangeBroadcasters';
7 | import { initialState } from './reducers/initialState';
8 | import { reducer } from './reducers/reducer';
9 | import { IAction, IProps, IState } from './types/';
10 |
11 | import { MultiSelect } from './components/MultiSelect';
12 | import { SingleSelect } from './components/SingleSelect';
13 |
14 | export class Select extends React.Component {
15 | public selectBox: HTMLDivElement | null;
16 | private reducer: (state: IState, action: IAction) => IState;
17 | private firstLoad: boolean;
18 |
19 | constructor(props: IProps) {
20 | super(props);
21 | this.state = initialState;
22 | this.reducer = reducer;
23 | this.firstLoad = true;
24 | this.selectBox = null;
25 | }
26 |
27 | public componentDidMount(): void {
28 | const { options, noSelectionLabel, selectedValue, selectedValues, name, multiselect, disabled } = this.props;
29 |
30 | this.updateState({
31 | type: actionTypes.INITIALISE,
32 | value: {
33 | options,
34 | noSelectionLabel,
35 | selectedValue,
36 | selectedValues,
37 | name,
38 | multiselect,
39 | disabled,
40 | },
41 | });
42 | }
43 |
44 | public componentDidUpdate(_prevProps: IProps, prevState: IState): boolean {
45 | const { singleSelectSelectedOption, multiSelectSelectedOptions, multiselect, altered } = this.state;
46 | const { onChange } = this.props;
47 |
48 | if (this.firstLoad) {
49 | this.firstLoad = false;
50 | return false;
51 | }
52 |
53 | if (multiselect) {
54 | multiSelectBroadcastChange(
55 | multiSelectSelectedOptions.options,
56 | Boolean(altered),
57 | onChange,
58 | prevState.multiSelectSelectedOptions.options
59 | );
60 | } else {
61 | singleSelectBroadcastChange(
62 | singleSelectSelectedOption,
63 | Boolean(altered),
64 | onChange,
65 | prevState.singleSelectSelectedOption
66 | );
67 | }
68 |
69 | return true;
70 | }
71 |
72 | public updateState(action: IAction, callback?: (nextState: IState) => any): void {
73 | const { onListen, name } = this.props;
74 | const nextState = this.reducer(this.state, action);
75 |
76 | this.setState(nextState, () => {
77 | if (callback) {
78 | callback(nextState);
79 | }
80 | });
81 |
82 | /* Allow user to listen to actions being fired */
83 | if (onListen) {
84 | const isOpen = [
85 | actionTypes.SET_OPTIONS_PANEL_OPEN,
86 | actionTypes.SET_NEXT_SELECTED_INDEX,
87 | actionTypes.SET_NEXT_SELECTED_INDEX_ALPHA_NUMERIC,
88 | actionTypes.SET_IS_DRAGGING,
89 | ].some((actionType: string) => action.type === actionType);
90 |
91 | onListen(isOpen, name, action.type);
92 | }
93 | }
94 |
95 | public focusButton(): void {
96 | const el: HTMLDivElement | null = this.selectBox && this.selectBox.querySelector('.rrs__button');
97 | // tslint:disable-next-line no-unused-expression
98 | el && el.focus();
99 | }
100 |
101 | public onHandleKeyEvent = (e: any): void => {
102 | handleKeyEvent({
103 | event: e,
104 | RRSClassRef: this,
105 | state: this.state,
106 | props: this.props,
107 | });
108 | };
109 |
110 | public onHandleTouchStart = (_e: any): void => {
111 | handleTouchStart({
112 | RRSClassRef: this,
113 | state: this.state,
114 | });
115 | };
116 |
117 | public onHandleTouchMove = (_e: any): void => {
118 | handleTouchMove({
119 | RRSClassRef: this,
120 | state: this.state,
121 | });
122 | };
123 |
124 | public onHandleClick = (e: any): void => {
125 | handleClick({
126 | event: e,
127 | RRSClassRef: this,
128 | state: this.state,
129 | props: this.props,
130 | });
131 | };
132 |
133 | public onHandleBlur = (_e: any): void => {
134 | handleBlur({
135 | RRSClassRef: this,
136 | state: this.state,
137 | props: this.props,
138 | });
139 | };
140 |
141 | public render(): React.ReactNode {
142 | const { prefix, caretIcon, modalCloseButton } = this.props;
143 | const {
144 | altered,
145 | disabled,
146 | hasOptHeaders,
147 | isOptionsPanelOpen,
148 | isDragging,
149 | noSelectionLabel,
150 | multiSelectSelectedIndexes,
151 | multiSelectSelectedOptions,
152 | name,
153 | nextPotentialSelectionIndex,
154 | options,
155 | singleSelectSelectedIndex,
156 | singleSelectSelectedOption,
157 | multiselect,
158 | } = this.state;
159 |
160 | const customLabelText = getCustomLabelText({
161 | props: this.props,
162 | state: this.state,
163 | });
164 |
165 | return (
166 | {
176 | this.selectBox = r;
177 | }}
178 | tabIndex={-1}
179 | onKeyDown={this.onHandleKeyEvent}
180 | onTouchStart={this.onHandleTouchStart}
181 | onTouchMove={this.onHandleTouchMove}
182 | onTouchEnd={this.onHandleClick}
183 | onMouseDown={this.onHandleClick}
184 | onBlur={this.onHandleBlur}
185 | >
186 | {!!modalCloseButton && isOptionsPanelOpen === true && (
187 |
188 | {modalCloseButton}
189 |
190 | )}
191 | {multiselect ? (
192 |
206 | ) : (
207 |
221 | )}
222 |
223 | );
224 | }
225 | }
226 |
227 | export * from './Extras';
228 | export * from './types/';
229 |
--------------------------------------------------------------------------------
/src/reducers/initialState.ts:
--------------------------------------------------------------------------------
1 | export const initialState = {
2 | // Constants
3 | multiselect: false,
4 |
5 | // Universal
6 | name: '',
7 | options: [],
8 | isDragging: false,
9 | isOptionsPanelOpen: false,
10 | altered: false,
11 |
12 | // Single select
13 | singleSelectInitialIndex: 0,
14 | singleSelectSelectedIndex: 0,
15 | singleSelectSelectedOption: {},
16 |
17 | // For determining highlighted item on Keyboard navigation
18 | nextPotentialSelectionIndex: 0,
19 |
20 | // Multi select
21 | multiSelectInitialSelectedIndexes: [0],
22 | multiSelectSelectedOptions: {
23 | altered: false,
24 | options: [],
25 | },
26 | multiSelectSelectedIndexes: [],
27 | };
28 |
--------------------------------------------------------------------------------
/src/reducers/lib/addMultiSelectIndex.ts:
--------------------------------------------------------------------------------
1 | import { IState } from '../../types/';
2 |
3 | export function addMultiSelectIndex(state: IState, index: number): number[] {
4 | return [...state.multiSelectSelectedIndexes, index];
5 | }
6 |
--------------------------------------------------------------------------------
/src/reducers/lib/addMultiSelectOption.ts:
--------------------------------------------------------------------------------
1 | import { IOutputMultiSelect, IState } from '../../types/';
2 |
3 | export function addMultiSelectOption(state: IState, index: number): IOutputMultiSelect {
4 | return {
5 | options: [
6 | ...state.multiSelectSelectedOptions.options,
7 | {
8 | name: state.name,
9 | text: state.options[index].text,
10 | value: state.options[index].value,
11 | },
12 | ],
13 | };
14 | }
15 |
--------------------------------------------------------------------------------
/src/reducers/lib/getInitialMultiSelectOption.ts:
--------------------------------------------------------------------------------
1 | import { IOption, IState } from '../../types/';
2 |
3 | interface IOutputMultiSelectOptionSansDisabled {
4 | value?: string;
5 | text?: string;
6 | name?: string;
7 | }
8 |
9 | interface IFindClosestValidOptionOutput {
10 | option: IOutputMultiSelectOptionSansDisabled;
11 | index: number;
12 | }
13 |
14 | function findClosestValidOption(state: IState): { option: IOutputMultiSelectOptionSansDisabled; index: number } {
15 | const { options, name } = state;
16 | const possibleOptions = options.reduce((acc: IFindClosestValidOptionOutput[], option: IOption, index: number) => {
17 | if (!option.optHeader) {
18 | acc.push({
19 | option: { value: option.value, text: option.text, name },
20 | index,
21 | });
22 | }
23 | return acc;
24 | }, []);
25 |
26 | // Note: Will fail if there is only one option, and it is an optHeader
27 | return possibleOptions[0];
28 | }
29 |
30 | export function getInitialMultiSelectOption(state: IState): IState {
31 | //: { option: IOutputMultiSelectOptionSansDisabled; index: number; }
32 | const { option, index } = findClosestValidOption(state);
33 |
34 | return {
35 | ...state,
36 | multiSelectSelectedIndexes: [index],
37 | multiSelectSelectedOptions: { options: [{ ...option }] },
38 | nextPotentialSelectionIndex: index,
39 | };
40 | }
41 |
--------------------------------------------------------------------------------
/src/reducers/lib/getMultiSelectInitialSelectedOptions.ts:
--------------------------------------------------------------------------------
1 | import { IOption, IOutputMultiSelectOption, IState } from '../../types/';
2 |
3 | /* Use existing state.singleSelectSelectedOption, or first possible option to use as a selection */
4 | function findClosestValidOption(state: IState): IOutputMultiSelectOption {
5 | const { multiSelectSelectedOptions, options, name } = state;
6 |
7 | if (multiSelectSelectedOptions && multiSelectSelectedOptions.options.length) {
8 | return multiSelectSelectedOptions.options[0];
9 | }
10 |
11 | const possibleOptions = options.reduce((acc: IOption[], option: IOption) => {
12 | if (!option.optHeader) acc.push(option);
13 | return acc;
14 | }, []);
15 |
16 | // Note: Will fail if no non-optHeader options exist
17 | return {
18 | name,
19 | text: possibleOptions[0].text,
20 | value: possibleOptions[0].value,
21 | };
22 | }
23 |
24 | export function getMultiSelectInitialSelectedOptions(
25 | state: IState,
26 | selectedValues?: string[]
27 | ): IOutputMultiSelectOption[] {
28 | const { noSelectionLabel, options, name } = state;
29 |
30 | let selectedOptionsToReturn;
31 |
32 | if (!noSelectionLabel) {
33 | // Preselect the first item in the list when if no noSelectionLabel exists
34 | if (selectedValues && selectedValues.length > 0) {
35 | // Grab selected options by matching option.value with selectedValues, and merge in `name`
36 | selectedOptionsToReturn = options
37 | .filter((option: IOption) => selectedValues.some((selectedValue: string) => selectedValue === option.value))
38 | .map((option: IOption) => ({ name, ...option }));
39 | } else {
40 | // Grab first option and merge in `name`
41 | const option = options[0] && options[0].optHeader ? findClosestValidOption(state) : options[0];
42 |
43 | selectedOptionsToReturn = [
44 | {
45 | name,
46 | text: option.text,
47 | value: option.value,
48 | },
49 | ];
50 | }
51 |
52 | return selectedOptionsToReturn;
53 | }
54 |
55 | selectedOptionsToReturn =
56 | selectedValues && selectedValues.length > 0
57 | ? options.reduce((acc: any[], option: IOption) => {
58 | if (selectedValues.some((selectedValue: string) => selectedValue === option.value)) {
59 | acc.push({ ...option });
60 | }
61 | return acc;
62 | }, [])
63 | : [
64 | {
65 | name: state.name,
66 | text: noSelectionLabel,
67 | value: 'null',
68 | },
69 | ];
70 |
71 | return selectedOptionsToReturn;
72 | }
73 |
--------------------------------------------------------------------------------
/src/reducers/lib/getMultiSelectSelectedValueIndexes.ts:
--------------------------------------------------------------------------------
1 | import { nextValidIndex } from '../../lib/nextValidIndex';
2 | import { IState } from '../../types/';
3 |
4 | export function getMultiSelectSelectedValueIndexes(
5 | state: IState,
6 | selectedValues: string[] = [],
7 | noSelectionLabel?: string
8 | ): number[] {
9 | const { options } = state;
10 | const emptyResult = noSelectionLabel ? [] : [nextValidIndex(state, 0)];
11 |
12 | /* return the index of the found item, if found */
13 | const result = options.reduce((acc: any, option: any, value: number) => {
14 | if (selectedValues.some((selected: string) => option.value === selected)) {
15 | acc.push(value);
16 | }
17 | return acc;
18 | }, []);
19 |
20 | /* If something found return that, else return the first item */
21 | return result.length > 0 ? result : emptyResult;
22 | }
23 |
--------------------------------------------------------------------------------
/src/reducers/lib/getSelectedValueIndex.ts:
--------------------------------------------------------------------------------
1 | import { IOption } from '../../types/';
2 |
3 | interface TArgs {
4 | options: IOption[];
5 | selectedValue: string;
6 | noSelectionLabel: string;
7 | }
8 |
9 | export function getSelectedValueIndex({ options, selectedValue, noSelectionLabel }: TArgs): number {
10 | const index = selectedValue ? options.map((option: IOption) => option.value).indexOf(selectedValue) : -1;
11 |
12 | // Allow a negative index if user wants to display a noSelectionLabel
13 | // Keyboard will not focus on an option when first opened
14 |
15 | // Select the first option when panel opens if !noSelectionLabel
16 | return index > -1 || noSelectionLabel ? index : 0;
17 | }
18 |
--------------------------------------------------------------------------------
/src/reducers/lib/getSingleSelectSelectedOption.ts:
--------------------------------------------------------------------------------
1 | import { IOption, IState } from '../../types/';
2 |
3 | /*
4 | use existing state.singleSelectSelectedOption, or first possible option to use as a selection
5 | */
6 | function closestValidOption(state: IState): IOption & { name?: string } {
7 | if (state.singleSelectSelectedOption) {
8 | return state.singleSelectSelectedOption;
9 | }
10 |
11 | const possibleOptions: IOption[] = state.options.reduce((acc: IOption[], option: IOption): IOption[] => {
12 | if (!option.optHeader) {
13 | acc.push(option);
14 | }
15 | return acc;
16 | }, []);
17 |
18 | // Note: Will fail if no non-optHeader options exist
19 | return {
20 | ...possibleOptions[0],
21 | name: state.name,
22 | };
23 | }
24 |
25 | export function getSingleSelectSelectedOption(
26 | state: IState,
27 | initialSelectedIndex: number = 0
28 | ): IOption & { name?: string } {
29 | const selectionIndex = initialSelectedIndex === -1 && !state.noSelectionLabel ? 0 : initialSelectedIndex;
30 |
31 | // if optHeader, then use existing state.singleSelectSelectedOption, or findClosestValidOption
32 | if (state.options[selectionIndex] && state.options[selectionIndex].optHeader) {
33 | return closestValidOption(state);
34 | }
35 |
36 | // Has selection, has no selection use default noSelectionLabel (if exists) and nullify value
37 | if (!state.noSelectionLabel) {
38 | // Preselect the first item in the list when if no noSelectionLabel exists
39 | return {
40 | name: state.name,
41 | ...state.options[selectionIndex],
42 | };
43 | }
44 |
45 | return initialSelectedIndex > -1
46 | ? {
47 | name: state.name,
48 | ...state.options[initialSelectedIndex],
49 | }
50 | : {
51 | name: state.name,
52 | text: state.noSelectionLabel,
53 | value: 'null',
54 | };
55 | }
56 |
--------------------------------------------------------------------------------
/src/reducers/lib/index.ts:
--------------------------------------------------------------------------------
1 | export { addMultiSelectIndex } from './addMultiSelectIndex';
2 | export { addMultiSelectOption } from './addMultiSelectOption';
3 | export { getInitialMultiSelectOption } from './getInitialMultiSelectOption';
4 | export { getMultiSelectInitialSelectedOptions } from './getMultiSelectInitialSelectedOptions';
5 | export { getMultiSelectSelectedValueIndexes } from './getMultiSelectSelectedValueIndexes';
6 | export { getSelectedValueIndex } from './getSelectedValueIndex';
7 | export { getSingleSelectSelectedOption } from './getSingleSelectSelectedOption';
8 | export { mergeIsAlteredState } from './mergeIsAlteredState';
9 | export { removeMultiSelectIndex } from './removeMultiSelectIndex';
10 | export { removeMultiSelectOption } from './removeMultiSelectOption';
11 | export { resetMultiSelectState } from './resetMultiSelectState';
12 |
--------------------------------------------------------------------------------
/src/reducers/lib/mergeIsAlteredState.ts:
--------------------------------------------------------------------------------
1 | import { IState } from '../../types/';
2 |
3 | export function isAltered(newState: IState): boolean {
4 | return !newState.multiselect
5 | ? newState.singleSelectSelectedIndex !== newState.singleSelectInitialIndex
6 | : !(
7 | JSON.stringify(newState.multiSelectInitialSelectedIndexes) ===
8 | JSON.stringify(newState.multiSelectSelectedIndexes)
9 | );
10 | }
11 |
12 | export function mergeIsAlteredState(newState: IState): IState {
13 | return {
14 | ...newState,
15 | altered: isAltered(newState),
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/src/reducers/lib/removeMultiSelectIndex.ts:
--------------------------------------------------------------------------------
1 | import { IState } from '../../types/';
2 |
3 | export function removeMultiSelectIndex(state: IState, indexLocation: number): number[] {
4 | return [
5 | ...state.multiSelectSelectedIndexes.slice(0, indexLocation),
6 | ...state.multiSelectSelectedIndexes.slice(indexLocation + 1),
7 | ];
8 | }
9 |
--------------------------------------------------------------------------------
/src/reducers/lib/removeMultiSelectOption.ts:
--------------------------------------------------------------------------------
1 | import { IOutputMultiSelectOption, IState } from '../../types/';
2 |
3 | export function removeMultiSelectOption(
4 | state: IState,
5 | indexLocation: number
6 | ): {
7 | options: IOutputMultiSelectOption[];
8 | } {
9 | return {
10 | options: [
11 | ...state.multiSelectSelectedOptions.options.slice(0, indexLocation),
12 | ...state.multiSelectSelectedOptions.options.slice(indexLocation + 1),
13 | ],
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/src/reducers/lib/resetMultiSelectState.ts:
--------------------------------------------------------------------------------
1 | import { IState } from '../../types/';
2 | import { initialState } from '../initialState';
3 |
4 | export function resetMultiSelectState(state: IState): IState {
5 | return {
6 | // reset multiSelect state
7 | ...state,
8 | multiSelectSelectedIndexes: [...initialState.multiSelectSelectedIndexes],
9 | multiSelectSelectedOptions: { ...initialState.multiSelectSelectedOptions },
10 | };
11 | }
12 |
--------------------------------------------------------------------------------
/src/reducers/reducer.ts:
--------------------------------------------------------------------------------
1 | import * as actionTypes from '../constants/actionTypes';
2 | import { nextValidIndex } from '../lib/nextValidIndex';
3 | import { IAction, IOption, IState } from '../types/';
4 |
5 | import {
6 | addMultiSelectIndex,
7 | addMultiSelectOption,
8 | getInitialMultiSelectOption,
9 | getMultiSelectInitialSelectedOptions,
10 | getMultiSelectSelectedValueIndexes,
11 | getSelectedValueIndex,
12 | getSingleSelectSelectedOption,
13 | mergeIsAlteredState,
14 | removeMultiSelectIndex,
15 | removeMultiSelectOption,
16 | resetMultiSelectState,
17 | } from './lib';
18 |
19 | export function reducer(state: IState, action: IAction): IState {
20 | switch (action.type) {
21 | case actionTypes.INITIALISE: {
22 | const initialSelectedIndex = getSelectedValueIndex(action.value);
23 | const initialSelectedIndexes = getMultiSelectSelectedValueIndexes(
24 | action.value,
25 | action.value.selectedValues,
26 | action.value.noSelectionLabel
27 | );
28 |
29 | return {
30 | ...state,
31 |
32 | hasOptHeaders: action.value.options.some((option: IOption) => option.optHeader === true),
33 |
34 | // Constants
35 | multiselect: action.value.multiselect || false,
36 |
37 | // Optional nothing selected label
38 | noSelectionLabel: action.value.noSelectionLabel,
39 |
40 | // Universal
41 | name: action.value.name,
42 | options: action.value.options,
43 | altered: action.value.altered || false,
44 | disabled: action.value.options.length === 0 || action.value.disabled || false,
45 |
46 | // Single select
47 | singleSelectInitialIndex: initialSelectedIndex,
48 | singleSelectSelectedIndex: initialSelectedIndex,
49 | singleSelectSelectedOption: getSingleSelectSelectedOption(action.value, initialSelectedIndex),
50 |
51 | nextPotentialSelectionIndex: state.nextPotentialSelectionIndex
52 | ? state.nextPotentialSelectionIndex
53 | : initialSelectedIndex,
54 |
55 | // Multi select
56 | multiSelectInitialSelectedIndexes: initialSelectedIndexes,
57 | multiSelectSelectedIndexes: initialSelectedIndexes,
58 | multiSelectSelectedOptions: {
59 | options:
60 | action.value.options.length > 0
61 | ? getMultiSelectInitialSelectedOptions(action.value, action.value.selectedValues)
62 | : [],
63 | },
64 | };
65 | }
66 |
67 | case actionTypes.SET_IS_DRAGGING:
68 | return {
69 | ...state,
70 | isDragging: action.value,
71 | };
72 |
73 | case actionTypes.SET_OPTIONS_PANEL_OPEN: {
74 | const newState = {
75 | ...state,
76 | isOptionsPanelOpen: true,
77 |
78 | // For determining highlighted item on Keyboard navigation
79 | nextPotentialSelectionIndex: ((): number => {
80 | if (state.multiselect) {
81 | return state.multiSelectSelectedIndexes.length
82 | ? nextValidIndex(state, state.multiSelectSelectedIndexes[0])
83 | : nextValidIndex(state, 0);
84 | }
85 | return nextValidIndex(state, state.nextPotentialSelectionIndex);
86 | })(),
87 |
88 | singleSelectSelectedOption: getSingleSelectSelectedOption(state, state.nextPotentialSelectionIndex),
89 | };
90 |
91 | return mergeIsAlteredState(newState);
92 | }
93 |
94 | case actionTypes.SET_OPTIONS_PANEL_CLOSED: {
95 | const newState = {
96 | ...state,
97 | isOptionsPanelOpen: false,
98 | singleSelectSelectedIndex: state.nextPotentialSelectionIndex,
99 | singleSelectSelectedOption: getSingleSelectSelectedOption(state, state.nextPotentialSelectionIndex),
100 | };
101 | return mergeIsAlteredState(newState);
102 | }
103 |
104 | case actionTypes.SET_OPTIONS_PANEL_CLOSED_NO_SELECTION:
105 | case actionTypes.SET_OPTIONS_PANEL_CLOSED_ONBLUR:
106 | return {
107 | ...state,
108 | isOptionsPanelOpen: false,
109 | };
110 |
111 | case actionTypes.SET_NEXT_SELECTED_INDEX:
112 | return {
113 | ...state,
114 | nextPotentialSelectionIndex: action.value,
115 | };
116 |
117 | case actionTypes.SET_NEXT_SELECTED_INDEX_ALPHA_NUMERIC:
118 | return {
119 | ...state,
120 | isOptionsPanelOpen: true,
121 | nextPotentialSelectionIndex: action.value,
122 | };
123 |
124 | case actionTypes.SET_SINGLESELECT_OPTIONS: {
125 | const nextState = {
126 | ...state,
127 | nextPotentialSelectionIndex: action.value,
128 | singleSelectSelectedIndex: action.value,
129 | isOptionsPanelOpen: false,
130 | singleSelectSelectedOption: getSingleSelectSelectedOption(state, action.value),
131 | };
132 |
133 | // Set altered state
134 | return mergeIsAlteredState(nextState);
135 | }
136 |
137 | case actionTypes.SET_MULTISELECT_OPTIONS: {
138 | if (!state.noSelectionLabel) {
139 | const isFirstOptionInListSelected =
140 | state.multiSelectSelectedIndexes[0] === 0 && state.multiSelectSelectedIndexes.length === 1;
141 |
142 | // If anything selected and first option was requested, deselect all, then select first option
143 | const shouldDeselectAllAndSelectFirstOption =
144 | state.multiSelectSelectedIndexes.length > 0 &&
145 | !isFirstOptionInListSelected &&
146 | action.value === 0 &&
147 | !state.noSelectionLabel;
148 |
149 | // Deselect first option when any other value is requested
150 | const shouldDeselectFirstOptionAndSelectRequestedOption = isFirstOptionInListSelected && action.value !== 0;
151 |
152 | // If any thing selected and first option was requested, deselect all, and return first option
153 | if (shouldDeselectAllAndSelectFirstOption) {
154 | return mergeIsAlteredState(getInitialMultiSelectOption(state));
155 | }
156 |
157 | // Deselect first option when first option selected and another option is requested
158 | if (shouldDeselectFirstOptionAndSelectRequestedOption) {
159 | // eslint-disable-next-line no-param-reassign
160 | state = resetMultiSelectState(state);
161 | }
162 | }
163 |
164 | // Remove noSelectionLabel from selected options if something is selected
165 | if (state.noSelectionLabel && state.multiSelectSelectedOptions.options[0].text === state.noSelectionLabel) {
166 | // eslint-disable-next-line no-param-reassign
167 | state.multiSelectSelectedOptions.options = [];
168 | }
169 |
170 | // With optHeader, action.value can go out of bounds - check and adjust the value of value when requried
171 | const actionOptionIndexAdjusted = nextValidIndex(state, action.value);
172 |
173 | // Find index of requested option
174 | const indexLocation = state.multiSelectSelectedIndexes.indexOf(actionOptionIndexAdjusted);
175 |
176 | // If requested item does not exist, add it. Else remove it
177 | let nextState = {
178 | ...state,
179 | nextPotentialSelectionIndex: actionOptionIndexAdjusted,
180 | multiSelectSelectedIndexes:
181 | indexLocation === -1
182 | ? addMultiSelectIndex(state, actionOptionIndexAdjusted)
183 | : removeMultiSelectIndex(state, indexLocation),
184 | multiSelectSelectedOptions:
185 | indexLocation === -1
186 | ? addMultiSelectOption(state, actionOptionIndexAdjusted)
187 | : removeMultiSelectOption(state, indexLocation),
188 | };
189 |
190 | if (nextState.multiSelectSelectedOptions.options.length === 0) {
191 | // Reset to noSelectionLabel if user has deselected all items and has set a `noSelectionLabel` prop
192 | if (state.noSelectionLabel) {
193 | nextState = {
194 | ...nextState,
195 | nextPotentialSelectionIndex: state.hasOptHeaders ? nextValidIndex(state, -1) : -1,
196 | multiSelectSelectedOptions: {
197 | options: getMultiSelectInitialSelectedOptions(state),
198 | },
199 | };
200 | } else if (!state.noSelectionLabel) {
201 | // Select first option if user has deselected all items
202 | nextState = getInitialMultiSelectOption(state);
203 | }
204 | }
205 | // Set altered state
206 | return mergeIsAlteredState(nextState);
207 | }
208 | default:
209 | return state;
210 | }
211 | }
212 |
--------------------------------------------------------------------------------
/src/styleguide/StyleguidistStyle.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans',
3 | 'Droid Sans', 'Helvetica Neue', sans-serif;
4 | }
5 |
6 | a {
7 | color: rgb(22, 115, 177);
8 | }
9 |
10 | h1 {
11 | line-height: 1 !important;
12 | padding: 0 0 0 0 !important;
13 | margin: 2rem 0 !important;
14 | font-size: 2rem !important;
15 | font-weight: bold !important;
16 | display: block !important;
17 | }
18 |
19 | h2 {
20 | border-top: #9fc5d4 solid 2px;
21 | line-height: 1;
22 | padding: 2rem 0 0 0;
23 | margin: 0 0 1rem 0;
24 | font-size: 1rem;
25 | display: block;
26 | }
27 |
28 | h2.subh1 {
29 | font-weight: 400;
30 | margin: 0 0 2rem 0;
31 | padding: 0;
32 | line-height: 1.4;
33 | border: 0;
34 | }
35 |
36 | .row:first-of-type h2 {
37 | border-top-width: 0px;
38 | }
39 |
40 | h3 {
41 | font-weight: 400;
42 | margin: 0 0 1rem 0;
43 | }
44 |
45 | h4 {
46 | margin: 0 0 0.5rem;
47 | font-weight: 400;
48 | }
49 |
50 | ul {
51 | padding: 0 0 2rem 0;
52 | margin: 1rem 0 0 0;
53 | }
54 |
55 | li {
56 | margin: 0 0 4px 0;
57 | display: flex;
58 | }
59 |
60 | input[type='text'] {
61 | padding: 1rem;
62 | margin: 1rem 0;
63 | width: 100%;
64 | }
65 |
66 | label span {
67 | margin: 0 0.5rem;
68 | }
69 |
70 | .subh1p strong {
71 | font-weight: normal;
72 | }
73 |
74 | .label {
75 | margin: 8px 0 0 0;
76 | color: #757575;
77 | display: block;
78 | white-space: nowrap;
79 | overflow: hidden;
80 | text-overflow: ellipsis;
81 | }
82 |
83 | /*
84 |
85 | Demo styles
86 |
87 | */
88 |
89 | .demo-gif {
90 | width: 100%;
91 | max-width: 640px;
92 | border: 1px solid #ccc;
93 | border-radius: 3px;
94 | }
95 |
96 | /*
97 |
98 | Demo Grid
99 |
100 | */
101 |
102 | .row {
103 | display: block;
104 | }
105 |
106 | .row--hero {
107 | display: block;
108 | margin-bottom: 4rem;
109 | }
110 |
111 | .row--hero > * {
112 | flex: 1;
113 | margin-bottom: 2rem;
114 | }
115 |
116 | .row:after {
117 | content: '';
118 | display: table;
119 | clear: both;
120 | }
121 |
122 | .col {
123 | display: inline-block;
124 | width: 100%;
125 | max-width: 300px;
126 | margin: 0 1rem 1rem 0;
127 | }
128 |
129 | @media only screen and (min-width: 900px) {
130 | .row--hero {
131 | display: flex;
132 | }
133 | .row--hero > * {
134 | flex: 1;
135 | margin-bottom: 0;
136 | }
137 | }
138 |
139 | @media only screen and (max-width: 480px) {
140 | h1 {
141 | font-size: 1.25rem;
142 | }
143 | .col {
144 | max-width: 100%;
145 | margin: 0;
146 | }
147 | }
148 |
149 | .features-list {
150 | font-size: 1.1rem;
151 | list-style: none;
152 | padding: 0;
153 | margin: 0 0 4rem 0;
154 | }
155 |
156 | .features-list__item {
157 | display: flex;
158 | }
159 |
160 | .features-list__item h4 {
161 | margin: 0 0 0.5rem 0;
162 | font-size: 1rem;
163 | }
164 |
165 | .features-list__checkIcon {
166 | width: 30px;
167 | }
168 |
169 | .features-list__checkIcon .checkbox-icon {
170 | fill: #9fc5d4;
171 | width: 16px;
172 | height: 16px;
173 | position: relative;
174 | stroke-width: 1px;
175 | }
176 |
177 | .features-list__checkIcon .checkbox {
178 | border-color: #9fc5d4;
179 | width: 25px;
180 | height: 25px;
181 | margin: 0 0.75rem 0 0;
182 | }
183 |
184 | .logo-links__link {
185 | display: flex;
186 | width: 70px;
187 | font-weight: bold;
188 | }
189 |
190 | pre.code {
191 | background: #333;
192 | color: #ccc;
193 | padding: 1rem;
194 | border: 1px solid #ccc;
195 | border-radius: 2px;
196 | font-style: italic;
197 | }
198 |
199 | table {
200 | width: 100%;
201 | border: 1px solid #ccc;
202 | border-collapse: collapse;
203 | font-weight: normal;
204 | }
205 |
206 | tr {
207 | padding: 0;
208 | margin: 0;
209 | }
210 |
211 | tr:nth-child(odd) {
212 | background: #fff;
213 | }
214 |
215 | tr:nth-child(even) {
216 | background: #f7f7f7;
217 | }
218 |
219 | tr:first-of-type {
220 | position: sticky;
221 | top: 0;
222 | }
223 |
224 | td,
225 | th {
226 | border: 1px solid #ccc;
227 | border-width: 0 1px 1px 0;
228 | vertical-align: top;
229 | padding: 1rem !important;
230 | margin: 0;
231 | text-align: left;
232 | }
233 |
234 | td:last-of-type {
235 | border-right-width: 0;
236 | }
237 |
238 | .table-header {
239 | background: #fff;
240 | position: sticky;
241 | margin: 0 0 1rem 0;
242 | padding: 2rem 0 1rem;
243 | top: 0;
244 | }
245 |
246 | td pre {
247 | background: #333;
248 | color: #ccc;
249 | padding: 1rem;
250 | border: 1px solid #ccc;
251 | border-radius: 2px;
252 | font-style: italic;
253 | }
254 |
255 | @media screen and (max-width: 768px) {
256 | .no-scroll-y {
257 | overflow-y: hidden;
258 | }
259 | }
260 |
261 | .has-error {
262 | background: #ffebee;
263 | padding: 4px;
264 | }
265 |
266 | .has-error .rrs__label,
267 | .has-error .rss__button:focus .rrs__label,
268 | .has-error .rss__button:focus .rrs__label svg {
269 | border: 1px solid #f44336 !important;
270 | color: #f44336;
271 | }
272 |
273 | .has-error .rrs__label svg * {
274 | fill: #f44336;
275 | }
276 |
277 | .field-error-message {
278 | color: #f44336;
279 | font-size: 14px;
280 | padding: 0.5rem;
281 | display: flex;
282 | align-items: center;
283 | }
284 |
285 | button {
286 | padding: 0.5rem 1rem;
287 | border-style: none;
288 | border-radius: 4px;
289 | background-color: #2196f3;
290 | font-size: 0.85rem;
291 | color: #fff;
292 | cursor: pointer;
293 | outline: none;
294 | -webkit-appearance: none;
295 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2);
296 | }
297 |
298 | button:disabled {
299 | opacity: 0.5;
300 | cursor: not-allowed !important;
301 | }
302 |
303 | button + button {
304 | margin-left: 0.5rem;
305 | }
306 |
307 | button.outline {
308 | background-color: #eee;
309 | border: 1px solid #aaa;
310 | color: #555;
311 | }
312 |
313 | .form {
314 | display: flex;
315 | justify-content: space-between;
316 | }
317 |
318 | .form__item {
319 | flex: 0 1 49%;
320 | }
321 |
--------------------------------------------------------------------------------
/src/styleguide/Wrapper.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import '../react-responsive-select.css';
3 | import './StyleguidistStyle.css';
4 |
5 | const Wrapper: React.FC = ({ children }) => <>{children}>;
6 |
7 | export default Wrapper;
8 |
--------------------------------------------------------------------------------
/src/types/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export interface IProps {
4 | name: string;
5 | options: Array<{
6 | text?: string;
7 | value?: string;
8 | optHeader?: boolean;
9 | markup?: React.ReactNode;
10 | disabled?: boolean;
11 | }>;
12 | noSelectionLabel?: string;
13 | onSubmit?: (event: any) => void;
14 | /**
15 | * singleselect mode | multiselect mode
16 | */
17 | onChange?: (
18 | changes:
19 | | {
20 | altered?: boolean; // Property added when outputed via onChange, onBlur etc. Passed around without this property
21 | text?: string;
22 | name?: string;
23 | value?: string;
24 | }
25 | | {
26 | altered?: boolean; // Property added when outputed via onChange, onBlur etc. Passed around without this property
27 | options: Array<{
28 | text?: string;
29 | name?: string;
30 | value?: string;
31 | }>;
32 | }
33 | ) => void;
34 | /**
35 | * singleselect mode | multiselect mode
36 | */
37 | onBlur?: (
38 | changes:
39 | | {
40 | altered?: boolean; // Property added when outputed via onChange, onBlur etc. Passed around without this property
41 | text?: string;
42 | name?: string;
43 | value?: string;
44 | }
45 | | {
46 | altered?: boolean; // Property added when outputed via onChange, onBlur etc. Passed around without this property
47 | options: Array<{
48 | text?: string;
49 | name?: string;
50 | value?: string;
51 | }>;
52 | }
53 | ) => void;
54 | caretIcon?: React.ReactNode;
55 | selectedValue?: string;
56 | prefix?: string;
57 | disabled?: boolean;
58 | /**
59 | * singleselect mode | multiselect mode
60 | */
61 | customLabelRenderer?: (
62 | selected:
63 | | {
64 | text?: string;
65 | value?: string;
66 | disabled?: boolean;
67 | }
68 | | {
69 | options: Array<{
70 | text?: string;
71 | value?: string;
72 | disabled?: boolean;
73 | }>;
74 | }
75 | ) => React.ReactNode;
76 | multiselect?: boolean;
77 | selectedValues?: string[];
78 | /**
79 | * `onListen` is handy for those situations where you need to change something potentially outside of your
80 | * control, e.g. setting a class on when the options panel opens to inhibit body scrolling.
81 | */
82 | onListen?: (isOpen?: boolean, name?: string, actionType?: string) => void;
83 | /**
84 | * `onSelect` passes back the option you just selected (singleselect/multiselect)
85 | */
86 | onSelect?: (option: IOption) => void;
87 | /**
88 | * `onDeselect` passes back the option you just deselected (multiselect)
89 | */
90 | onDeselect?: (option: IOption) => void;
91 | /**
92 | * Add a close button for when the the mobile view shows the selection modal.
93 | * You'll essentially be clicking the background so this is purely visual.
94 | */
95 | modalCloseButton?: React.ReactNode;
96 | }
97 |
98 | export interface IState {
99 | altered?: boolean;
100 | disabled?: boolean;
101 | hasOptHeaders?: boolean;
102 | noSelectionLabel?: string;
103 | isDragging: boolean;
104 | isOptionsPanelOpen: boolean;
105 | multiSelectInitialSelectedIndexes: number[];
106 | multiSelectSelectedIndexes: number[];
107 | multiSelectSelectedOptions: {
108 | altered?: boolean;
109 | options: Array<{
110 | value?: string;
111 | text?: string;
112 | name?: string;
113 | }>;
114 | };
115 | multiselect: boolean;
116 | name: string;
117 | nextPotentialSelectionIndex: number;
118 | options: Array<{
119 | text?: string;
120 | value?: string;
121 | optHeader?: boolean;
122 | markup?: React.ReactNode;
123 | disabled?: boolean;
124 | }>;
125 | singleSelectInitialIndex: number;
126 | singleSelectSelectedIndex: number;
127 | singleSelectSelectedOption: {
128 | altered?: boolean; // Property added when outputed via onChange, onBlur etc. Passed around without this property
129 | text?: string;
130 | name?: string;
131 | value?: string;
132 | };
133 | }
134 |
135 | /*
136 |
137 | Export Interfaces That are reused within /src. Not used above because
138 | props output in storybook is better when verbosely stated
139 |
140 | */
141 |
142 | export interface IOption {
143 | text?: string;
144 | value?: string;
145 | optHeader?: boolean;
146 | markup?: React.ReactNode;
147 | disabled?: boolean;
148 | }
149 |
150 | export interface IOutputSingleSelect {
151 | name?: string;
152 | text?: string;
153 | value?: string;
154 | altered?: boolean;
155 | }
156 |
157 | export interface IOutputMultiSelect {
158 | options: Array<{
159 | name?: string;
160 | text?: string;
161 | value?: string;
162 | }>;
163 | altered?: boolean;
164 | }
165 |
166 | export interface IOutputMultiSelectOption {
167 | name?: string;
168 | text?: string;
169 | value?: string;
170 | disabled?: boolean;
171 | }
172 |
173 | export interface IAction {
174 | type: string;
175 | value?: any;
176 | }
177 |
--------------------------------------------------------------------------------
/styleguide.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | title: 'React Responsive Select',
5 | theme: {
6 | sidebarWidth: 280,
7 | },
8 | styleguideComponents: {
9 | Wrapper: path.join(__dirname, 'src/styleguide/Wrapper.tsx'),
10 | },
11 | pagePerSection: true,
12 | exampleMode: 'expand',
13 | sections: [
14 | {
15 | name: 'Introduction',
16 | content: 'src/docs/home.md',
17 | },
18 | {
19 | name: 'API',
20 | content: 'src/docs/api.md',
21 | },
22 | {
23 | name: 'Screen reader demo',
24 | content: 'src/docs/screen-reader-demo.md',
25 | },
26 | {
27 | name: 'Single Select',
28 | pagePerSection: true,
29 | exampleMode: 'expand',
30 | sectionDepth: 1,
31 | sections: [
32 | {
33 | name: 'Basic',
34 | content: 'src/docs/singleselect/basic.md',
35 | },
36 | {
37 | name: 'Disabled',
38 | content: 'src/docs/singleselect/disabled.md',
39 | },
40 | {
41 | name: 'option.markup',
42 | content: 'src/docs/singleselect/option-markup.md',
43 | },
44 | {
45 | name: 'option.optHeader',
46 | content: 'src/docs/singleselect/optHeader.md',
47 | },
48 | {
49 | name: 'customLabelRenderer',
50 | content: 'src/docs/singleselect/customLabelRenderer.md',
51 | },
52 | {
53 | name: 'noSelectionLabel',
54 | content: 'src/docs/singleselect/noSelectionLabel.md',
55 | },
56 | ],
57 | },
58 | {
59 | name: 'Multi Select',
60 | pagePerSection: true,
61 | sectionDepth: 1,
62 | sections: [
63 | {
64 | name: 'Basic',
65 | content: 'src/docs/multiselect/basic.md',
66 | },
67 | {
68 | name: 'Disabled',
69 | content: 'src/docs/multiselect/disabled.md',
70 | },
71 | {
72 | name: 'noSelectionLabel',
73 | content: 'src/docs/multiselect/noSelectionLabel.md',
74 | },
75 | {
76 | name: 'option.optHeader',
77 | content: 'src/docs/multiselect/optHeader.md',
78 | },
79 | ],
80 | },
81 | {
82 | name: 'Recipes',
83 | pagePerSection: true,
84 | sectionDepth: 1,
85 | sections: [
86 | {
87 | name: 'Controlled example 1',
88 | content: 'src/docs/recipes/controlled-example-1.md',
89 | },
90 | {
91 | name: 'Controlled example 2',
92 | content: 'src/docs/recipes/controlled-example-2.md',
93 | },
94 | {
95 | name: 'Formik example',
96 | content: 'src/docs/recipes/formik.md',
97 | },
98 | {
99 | name: 'Listening for changes',
100 | content: 'src/docs/recipes/onListen.md',
101 | },
102 | {
103 | name: 'onSelect and onDeselect',
104 | content: 'src/docs/recipes/onSelectOnDeselect.md',
105 | },
106 | ],
107 | },
108 | {
109 | name: 'Npm',
110 | external: true,
111 | href: 'https://www.npmjs.com/package/react-responsive-select',
112 | },
113 | {
114 | name: 'Github',
115 | external: true,
116 | href: 'https://github.com/benbowes/react-responsive-select/',
117 | },
118 | ],
119 | // Custom webpack - only for Styleguidist
120 | webpackConfig: {
121 | module: {
122 | rules: [
123 | {
124 | test: /\.tsx?$/,
125 | loader: 'ts-loader',
126 | options: { transpileOnly: true, configFile: 'tsconfig.styleguidist.json' },
127 | },
128 | {
129 | test: /\.css$/,
130 | use: ['style-loader', 'css-loader'],
131 | },
132 | ],
133 | },
134 | },
135 | };
136 |
--------------------------------------------------------------------------------
/styleguide/build/bundle.04c1dcbf.js.LICENSE.txt:
--------------------------------------------------------------------------------
1 | /*
2 | object-assign
3 | (c) Sindre Sorhus
4 | @license MIT
5 | */
6 |
7 | /*!
8 | * @overview es6-promise - a tiny implementation of Promises/A+.
9 | * @copyright Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors (Conversion to ES6 API by Jake Archibald)
10 | * @license Licensed under MIT license
11 | * See https://raw.githubusercontent.com/stefanpenner/es6-promise/master/LICENSE
12 | * @version v4.2.8+1e68dce6
13 | */
14 |
15 | /*!
16 | * The buffer module from node.js, for the browser.
17 | *
18 | * @author Feross Aboukhadijeh
19 | * @license MIT
20 | */
21 |
22 | /*!
23 | * regjsgen 0.5.2
24 | * Copyright 2014-2020 Benjamin Tan
25 | * Available under the MIT license
26 | */
27 |
28 | /*! *****************************************************************************
29 | Copyright (c) Microsoft Corporation.
30 |
31 | Permission to use, copy, modify, and/or distribute this software for any
32 | purpose with or without fee is hereby granted.
33 |
34 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
35 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
36 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
37 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
38 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
39 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
40 | PERFORMANCE OF THIS SOFTWARE.
41 | ***************************************************************************** */
42 |
43 | /*! clipboard-copy. MIT License. Feross Aboukhadijeh */
44 |
45 | /*! https://mths.be/regenerate v1.4.1 by @mathias | MIT license */
46 |
47 | /**
48 | * A better abstraction over CSS.
49 | *
50 | * @copyright Oleg Isonen (Slobodskoi) / Isonen 2014-present
51 | * @website https://github.com/cssinjs/jss
52 | * @license MIT
53 | */
54 |
55 | /**
56 | * Prism: Lightweight, robust, elegant syntax highlighting
57 | *
58 | * @license MIT
59 | * @author Lea Verou
60 | * @namespace
61 | * @public
62 | */
63 |
64 | /** @license React v0.18.0
65 | * scheduler.production.min.js
66 | *
67 | * Copyright (c) Facebook, Inc. and its affiliates.
68 | *
69 | * This source code is licensed under the MIT license found in the
70 | * LICENSE file in the root directory of this source tree.
71 | */
72 |
73 | /** @license React v0.19.1
74 | * scheduler.production.min.js
75 | *
76 | * Copyright (c) Facebook, Inc. and its affiliates.
77 | *
78 | * This source code is licensed under the MIT license found in the
79 | * LICENSE file in the root directory of this source tree.
80 | */
81 |
82 | /** @license React v16.13.1
83 | * react-dom.production.min.js
84 | *
85 | * Copyright (c) Facebook, Inc. and its affiliates.
86 | *
87 | * This source code is licensed under the MIT license found in the
88 | * LICENSE file in the root directory of this source tree.
89 | */
90 |
91 | /** @license React v16.13.1
92 | * react-is.production.min.js
93 | *
94 | * Copyright (c) Facebook, Inc. and its affiliates.
95 | *
96 | * This source code is licensed under the MIT license found in the
97 | * LICENSE file in the root directory of this source tree.
98 | */
99 |
100 | /** @license React v16.13.1
101 | * react.production.min.js
102 | *
103 | * Copyright (c) Facebook, Inc. and its affiliates.
104 | *
105 | * This source code is licensed under the MIT license found in the
106 | * LICENSE file in the root directory of this source tree.
107 | */
108 |
--------------------------------------------------------------------------------
/styleguide/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | React Responsive Select
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "src",
4 | "types"
5 | ],
6 | "compilerOptions": {
7 | "module": "esnext",
8 | "lib": [
9 | "dom",
10 | "esnext"
11 | ],
12 | "importHelpers": true,
13 | "declaration": true,
14 | "sourceMap": true,
15 | "rootDir": "./src",
16 | "strict": true,
17 | "noUnusedLocals": true,
18 | "noUnusedParameters": true,
19 | "noImplicitReturns": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "moduleResolution": "node",
22 | "baseUrl": "./",
23 | "paths": {
24 | "*": [
25 | "src/*",
26 | "node_modules/*"
27 | ]
28 | },
29 | "jsx": "react",
30 | "esModuleInterop": true
31 | }
32 | }
--------------------------------------------------------------------------------
/tsconfig.styleguidist.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "skipLibCheck": true,
5 | },
6 | "include": [
7 | "src"
8 | ],
9 | "exclude": [
10 | "node_modules"
11 | ]
12 | }
--------------------------------------------------------------------------------
/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: [push]
3 | jobs:
4 | build:
5 | runs-on: ubuntu-latest
6 |
7 | steps:
8 | - name: Begin CI...
9 | uses: actions/checkout@v2
10 |
11 | - name: Use Node 12
12 | uses: actions/setup-node@v1
13 | with:
14 | node-version: 12.x
15 |
16 | - name: Use cached node_modules
17 | uses: actions/cache@v1
18 | with:
19 | path: node_modules
20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }}
21 | restore-keys: |
22 | nodeModules-
23 |
24 | - name: Install dependencies
25 | run: yarn install --frozen-lockfile
26 | env:
27 | CI: true
28 |
29 | - name: Lint
30 | run: yarn lint
31 | env:
32 | CI: true
33 |
34 | - name: Test
35 | run: yarn test --ci --coverage --maxWorkers=2
36 | env:
37 | CI: true
38 |
39 | - name: Build
40 | run: yarn build
41 | env:
42 | CI: true
43 |
--------------------------------------------------------------------------------