├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .storybook ├── main.js ├── preview-head.html ├── preview.js ├── style.css └── test-runner.js ├── LICENSE ├── README.md ├── __tests__ ├── __snapshots__ │ └── storybook.test.js.snap ├── data │ └── index.js ├── flattenOptions.test.js ├── getDisplayValue.test.js ├── getOption.test.js ├── getValue.test.js ├── groupOptions.test.js ├── highlight.test.js ├── isSelected.test.js ├── reduce.test.js ├── search.test.js ├── storybook.test.js └── updateOption.test.js ├── babel.config.json ├── doctor-storybook.log ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── SelectSearch.jsx ├── components │ ├── Option.jsx │ └── Options.jsx ├── index.d.ts ├── index.js ├── lib │ ├── classes.js │ ├── flattenOptions.js │ ├── fuzzySearch.js │ ├── getDisplayValue.js │ ├── getOption.js │ ├── getValue.js │ ├── groupOptions.js │ ├── highlight.js │ ├── isSame.js │ ├── isSelected.js │ ├── reduce.js │ ├── toArray.js │ └── updateOption.js ├── useHighlight.js ├── useOptions.js └── useSelect.js ├── stories ├── 0-Default.stories.js ├── 1-Multiple.stories.js ├── 2-Events.stories.js ├── 3-Custom.stories.js ├── 4-Async.stories.js ├── 5-Hooks.stories.js ├── 6-Misc.stories.js ├── assets │ └── hooks.module.css └── data │ └── index.js ├── style.css └── style.module.css /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | stories 3 | __tests__/** 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:react/recommended", "prettier"], 3 | "env": { 4 | "browser": true, 5 | "node": true, 6 | "es6": true 7 | }, 8 | "parserOptions": { 9 | "ecmaVersion": "latest", 10 | "sourceType": "module", 11 | "ecmaFeatures": { 12 | "jsx": true 13 | } 14 | }, 15 | "rules": { 16 | "react/react-in-jsx-scope": "off" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [tbleckert] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '26 23 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | coverage 4 | .vercel 5 | .parcel-cache 6 | dist 7 | public 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !dist/**/* 3 | !package.json 4 | !src/index.d.ts 5 | !style.css 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": true, 6 | "bracketSpacing": true 7 | } 8 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|ts|tsx)'], 3 | 4 | 'addons': [ 5 | '@storybook/addon-essentials', 6 | 'storybook-dark-mode', 7 | '@chromatic-com/storybook', 8 | '@storybook/addon-webpack5-compiler-babel' 9 | ], 10 | 11 | features: { 12 | postcss: false, 13 | }, 14 | 15 | webpackFinal: async config => { 16 | const devMode = process.env.NODE_ENV !== 'production'; 17 | 18 | // get index of css rule 19 | config.module.rules.find( 20 | rule => rule.test.toString() === '/\\.css$/', 21 | ).exclude = /\.module\.css$/; 22 | 23 | config.module.rules.push({ 24 | test: /\.module\.css$/, 25 | use: [ 26 | 'style-loader', 27 | { 28 | loader: 'css-loader', 29 | options: { 30 | modules: { 31 | localIdentName: (devMode) ? 'mm-[name]__[local]--[hash:base64:5]' : '[hash:base64:5]', 32 | }, 33 | } 34 | }, 35 | ] 36 | }); 37 | 38 | // Return the altered config 39 | return config; 40 | }, 41 | 42 | framework: { 43 | name: '@storybook/react-webpack5', 44 | options: {} 45 | }, 46 | 47 | docs: {}, 48 | 49 | typescript: { 50 | reactDocgen: 'react-docgen-typescript' 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 11 | 12 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import './style.css'; 2 | 3 | export const parameters = { 4 | darkMode: { 5 | stylePreview: true, 6 | darkClass: 'is-dark-mode', 7 | lightClass: 'is-light-mode' 8 | } 9 | }; 10 | export const tags = ['autodocs', 'autodocs']; 11 | -------------------------------------------------------------------------------- /.storybook/style.css: -------------------------------------------------------------------------------- 1 | body, body.is-light-mode { background: #fff; } 2 | 3 | @media (prefers-color-scheme: dark) { 4 | body { 5 | background: #000; 6 | } 7 | } 8 | 9 | body.is-dark-mode { 10 | background: #000; 11 | } 12 | -------------------------------------------------------------------------------- /.storybook/test-runner.js: -------------------------------------------------------------------------------- 1 | const { waitForPageReady } = require('@storybook/test-runner'); 2 | 3 | const { toMatchImageSnapshot } = require('jest-image-snapshot'); 4 | 5 | const customSnapshotsDir = `${process.cwd()}/__snapshots__`; 6 | 7 | /** @type { import('@storybook/test-runner').TestRunnerConfig } */ 8 | module.exports = { 9 | setup() { 10 | expect.extend({ toMatchImageSnapshot }); 11 | }, 12 | async postVisit(page, context) { 13 | // Waits for the page to be ready before taking a screenshot to ensure consistent results 14 | await waitForPageReady(page); 15 | 16 | // To capture a screenshot for different browsers, add page.context().browser().browserType().name() to get the browser name to prefix the file name 17 | const image = await page.screenshot(); 18 | expect(image).toMatchImageSnapshot({ 19 | customSnapshotsDir, 20 | customSnapshotIdentifier: context.id, 21 | }); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 tbleckert 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ⚠️ Looking for maintainers 2 | 3 | First of all, I want to thank you all for the amazing support over the years. This was one of my first open source projects and also my first successful one. I am beyond grateful for all the [33! contributors](https://github.com/tbleckert/react-select-search/graphs/contributors), wouldn't have been possible to run this project without you. 4 | 5 | A lot happened last year. I had my first born son, I had a few time consuming freelance gigs and a few startups that grew. This took time away from open source and I feel I can't do this component justice anymore. Not by myself at least. 6 | 7 | I still believe in a tiny, super fast and zero-dependency select component. If there's anyone out there that wants to co-maintain this with me, please reach out to discuss the next steps. Send me an email at hola@tobiasbleckert.se or hit me up on [Twitter](https://twitter.com/tbleckert) 8 | 9 |

10 | React Select Search 11 |

12 | 13 |
14 | Demo 15 | · 16 | Quick start 17 | · 18 | Config 19 | · 20 | Headless 21 |
22 | 23 |

24 | 25 | Coverage Status 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |

37 | 38 | ## Features 39 | * Lightweight, with zero dependencies 40 | * Accessible 41 | * Headless mode 42 | * Basic HTML select functionality, including multiple 43 | * Search/filter options 44 | * Async options 45 | * Apply renderers to change markup and behavior 46 | * Keyboard support 47 | * Group options with group names, you can search group names 48 | * Fully stylable 49 | 50 | ## Install 51 | 52 | Install it with npm (`npm i react-select-search`) or yarn (`yarn add react-select-search`) and import it like you normally would. 53 | 54 | ## Quick start 55 | 56 | ```jsx harmony 57 | import SelectSearch from 'react-select-search'; 58 | 59 | /** 60 | * The options array should contain objects. 61 | * Required keys are "name" and "value" but you can have and use any number of key/value pairs. 62 | */ 63 | const options = [ 64 | {name: 'Swedish', value: 'sv'}, 65 | {name: 'English', value: 'en'}, 66 | { 67 | type: 'group', 68 | name: 'Group name', 69 | items: [ 70 | {name: 'Spanish', value: 'es'}, 71 | ] 72 | }, 73 | ]; 74 | 75 | /* Simple example */ 76 | 77 | ``` 78 | For more examples, you can take a look in the [stories](stories) directory. 79 | 80 | You will also need some CSS to make it look right. Example theme can be found in [style.css](style.css). You can also import it: 81 | 82 | ```javascript 83 | import 'react-select-search/style.css' 84 | ``` 85 | 86 | ## Use with SSR 87 | 88 | For use with SSR you might need to use the commonjs bundle (react-select-search/dist/cjs). If you want to utilise the example theme ([style.css](style.css)) you need to check if your build script manipulates class names, for example minifies them. If that's the case, you can use CSS modules to get the class names from the style.css file and apply them using the [className object](#custom-class-names). Example can be seen [here](stories/3-Custom.stories.js#L64) as well as here https://react-select-search.com/?path=/story/custom--css-modules. 89 | 90 | ## Headless mode with hooks 91 | 92 | If you want complete control (more than styling and [custom renderers](#custom-renderers)) you can use hooks to pass data to your own components and build it yourself. 93 | 94 | ```jsx harmony 95 | import React from 'react'; 96 | import { useSelect } from 'react-select-search'; 97 | 98 | const CustomSelect = ({ options, value, multiple, disabled }) => { 99 | const [snapshot, valueProps, optionProps] = useSelect({ 100 | options, 101 | value, 102 | multiple, 103 | disabled, 104 | }); 105 | 106 | return ( 107 |
108 | 109 | {snapshot.focus && ( 110 | 117 | )} 118 |
119 | ); 120 | }; 121 | ``` 122 | 123 | ## Configuration 124 | 125 | Below is all the available options you can pass to the component. Options without defaults are required. 126 | 127 | | Name | Type | Default | Description | 128 | | ---- |----------------| ------- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------| 129 | | options | array | | See the [options documentation](#the-options-object) below | 130 | | getOptions | function | null | Get options through a function call, can return a promise for async usage. See [get options](#get-options) for more. | 131 | | filterOptions | array | null | An array of functions that takes the last filtered options and a search query if any. Runs after getOptions. | 132 | | value | string, array | null | The value should be an array if multiple mode. | 133 | | multiple | boolean | false | Set to true if you want to allow multiple selected options. | 134 | | search | boolean | false | Set to true to enable search functionality | 135 | | disabled | boolean | false | Disables all functionality | 136 | | closeOnSelect | boolean | true | The selectbox will blur by default when selecting an option. Set this to false to prevent this behavior. | 137 | | debounce | number | 0 | Number of ms to wait until calling [get options](#get-options) when searching. | 138 | | placeholder | string | empty string | Displayed if no option is selected and/or when search field is focused with empty value. | 139 | | id | string | null | HTML ID on the top level element. | 140 | | autoComplete | string, on/off | off | Disables/Enables autoComplete functionality in search field. | 141 | | autoFocus | boolean | false | Autofocus on select | 142 | | className | string, object | select-search-box | Set a base class string or pass a function for complete control. Se [custom classNames](#custom-class-names) for more. | 143 | | renderOption | function | null | Function that renders the options. See [custom renderers](#custom-renderers) for more. | 144 | | renderGroupHeader | function | null | Function that renders the group header. See [custom renderers](#custom-renderers) for more. | 145 | | renderValue | function | null | Function that renders the value/search field. See [custom renderers](#custom-renderers) for more. | 146 | | emptyMessage | React node | null | Set empty message for empty options list, you can provide render function without arguments instead plain string message | 147 | | onChange | function | null | Function to receive and handle value changes. | 148 | | onFocus | function | null | Focus callback. | 149 | | onBlur | function | null | Blur callback. | 150 | 151 | ## The options object 152 | 153 | The options object can contain any properties and values you like. The only required one is `name`. 154 | 155 | | Property | Type | Description | Required | 156 | | -------- | ---- | ----------- | -------- | 157 | | name | string | The name of the option | Yes | 158 | | value | string | The value of the option | Yes, if the type is not "group" | 159 | | type | string | If you set the type to "group" you can add an array of options that will be grouped | No | 160 | | items | array | Array of option objects that will be used if the type is set to "group" | Yes, if `type` is set to "group" | 161 | | disabled | boolean | Set to `true` to disable this option | No | 162 | 163 | ## Custom class names 164 | 165 | If you set a string as the `className` attribute value, the component will use that as a base for all elements. 166 | If you want to fully control the class names you can pass an object with classnames. The following keys exists: 167 | 168 | * container 169 | * value 170 | * input 171 | * select 172 | * options 173 | * row 174 | * option 175 | * group 176 | * group-header 177 | * is-selected 178 | * is-highlighted 179 | * is-loading 180 | * is-multiple 181 | * has-focus 182 | 183 | ## Custom renderers 184 | 185 | If CSS isn't enough, you can also control the HTML for the different parts of the component. 186 | 187 | | Callback | Args | Description | 188 | | -------- |-------------------------------------------------------------------------------------| ----------- | 189 | | renderOption | optionsProps: object, optionData: object, optionSnapshot: object, className: string | Controls the rendering of the options. | 190 | | renderGroupHeader | name: string | Controls the rendering of the group header name | 191 | | renderValue | valueProps: object, snapshot: object, className: string | Controls the rendering of the value/input element | 192 | 193 | The optionProps and the valueProps are needed for the component you render to work. For example: 194 | 195 | ```jsx 196 | } /> 197 | ``` 198 | 199 | Monkeypatch it if you need to but make sure to not remove important props. 200 | 201 | The optionSnapshot is an object that contains the object state: `{ selected: bool, highlighted: bool }`. 202 | 203 | ## Get options 204 | 205 | You can fetch options asynchronously with the `getOptions` property. You can either return options directly or through a `Promise`. 206 | 207 | ```jsx 208 | function getOptions(query) { 209 | return new Promise((resolve, reject) => { 210 | fetch(`https://www.thecocktaildb.com/api/json/v1/1/search.php?s=${query}`) 211 | .then(response => response.json()) 212 | .then(({ drinks }) => { 213 | resolve(drinks.map(({ idDrink, strDrink }) => ({ value: idDrink, name: strDrink }))) 214 | }) 215 | .catch(reject); 216 | }); 217 | } 218 | ``` 219 | 220 | The function runs on each search query update, so you might want to throttle the fetches. 221 | If you return a promise, the class `is-loading` will be applied to the main element, giving you a chance 222 | to change the appearance, like adding a spinner. The property `fetching` is also available in the snapshot that is sent to your render callbacks. 223 | 224 | ## Contributors 225 | 226 | 227 | 228 | 229 | 230 | Made with [contrib.rocks](https://contrib.rocks). 231 | -------------------------------------------------------------------------------- /__tests__/data/index.js: -------------------------------------------------------------------------------- 1 | export const countries = [ 2 | {name: 'Afghanistan', value: 'AF'}, 3 | {name: 'Åland Islands', value: 'AX'}, 4 | {name: 'Albania', value: 'AL'}, 5 | {name: 'Algeria', value: 'DZ'}, 6 | {name: 'American Samoa', value: 'AS'}, 7 | {name: 'AndorrA', value: 'AD'}, 8 | {name: 'Angola', value: 'AO'}, 9 | {name: 'Anguilla', value: 'AI'}, 10 | {name: 'Antarctica', value: 'AQ'}, 11 | {name: 'Antigua and Barbuda', value: 'AG'}, 12 | {name: 'Argentina', value: 'AR'}, 13 | {name: 'Armenia', value: 'AM'}, 14 | {name: 'Aruba', value: 'AW'}, 15 | {name: 'Australia', value: 'AU'}, 16 | {name: 'Austria', value: 'AT'}, 17 | {name: 'Azerbaijan', value: 'AZ'}, 18 | {name: 'Bahamas', value: 'BS'}, 19 | {name: 'Bahrain', value: 'BH'}, 20 | {name: 'Bangladesh', value: 'BD'}, 21 | {name: 'Barbados', value: 'BB'}, 22 | {name: 'Belarus', value: 'BY'}, 23 | {name: 'Belgium', value: 'BE'}, 24 | {name: 'Belize', value: 'BZ'}, 25 | {name: 'Benin', value: 'BJ'}, 26 | {name: 'Bermuda', value: 'BM'}, 27 | {name: 'Bhutan', value: 'BT'}, 28 | {name: 'Bolivia', value: 'BO'}, 29 | {name: 'Bosnia and Herzegovina', value: 'BA'}, 30 | {name: 'Botswana', value: 'BW'}, 31 | {name: 'Bouvet Island', value: 'BV'}, 32 | {name: 'Brazil', value: 'BR'}, 33 | {name: 'British Indian Ocean Territory', value: 'IO'}, 34 | {name: 'Brunei Darussalam', value: 'BN'}, 35 | {name: 'Bulgaria', value: 'BG'}, 36 | {name: 'Burkina Faso', value: 'BF'}, 37 | {name: 'Burundi', value: 'BI'}, 38 | {name: 'Cambodia', value: 'KH'}, 39 | {name: 'Cameroon', value: 'CM'}, 40 | {name: 'Canada', value: 'CA'}, 41 | {name: 'Cape Verde', value: 'CV'}, 42 | {name: 'Cayman Islands', value: 'KY'}, 43 | {name: 'Central African Republic', value: 'CF'}, 44 | {name: 'Chad', value: 'TD'}, 45 | {name: 'Chile', value: 'CL'}, 46 | {name: 'China', value: 'CN'}, 47 | {name: 'Christmas Island', value: 'CX'}, 48 | {name: 'Cocos (Keeling) Islands', value: 'CC'}, 49 | {name: 'Colombia', value: 'CO'}, 50 | {name: 'Comoros', value: 'KM'}, 51 | {name: 'Congo', value: 'CG'}, 52 | {name: 'Congo, The Democratic Republic of the', value: 'CD'}, 53 | {name: 'Cook Islands', value: 'CK'}, 54 | {name: 'Costa Rica', value: 'CR'}, 55 | {name: 'Cote D\'Ivoire', value: 'CI'}, 56 | {name: 'Croatia', value: 'HR'}, 57 | {name: 'Cuba', value: 'CU'}, 58 | {name: 'Cyprus', value: 'CY'}, 59 | {name: 'Czech Republic', value: 'CZ'}, 60 | {name: 'Denmark', value: 'DK'}, 61 | {name: 'Djibouti', value: 'DJ'}, 62 | {name: 'Dominica', value: 'DM'}, 63 | {name: 'Dominican Republic', value: 'DO'}, 64 | {name: 'Ecuador', value: 'EC'}, 65 | {name: 'Egypt', value: 'EG'}, 66 | {name: 'El Salvador', value: 'SV'}, 67 | {name: 'Equatorial Guinea', value: 'GQ'}, 68 | {name: 'Eritrea', value: 'ER'}, 69 | {name: 'Estonia', value: 'EE'}, 70 | {name: 'Ethiopia', value: 'ET'}, 71 | {name: 'Falkland Islands (Malvinas)', value: 'FK'}, 72 | {name: 'Faroe Islands', value: 'FO'}, 73 | {name: 'Fiji', value: 'FJ'}, 74 | {name: 'Finland', value: 'FI'}, 75 | {name: 'France', value: 'FR'}, 76 | {name: 'French Guiana', value: 'GF'}, 77 | {name: 'French Polynesia', value: 'PF'}, 78 | {name: 'French Southern Territories', value: 'TF'}, 79 | {name: 'Gabon', value: 'GA'}, 80 | {name: 'Gambia', value: 'GM'}, 81 | {name: 'Georgia', value: 'GE'}, 82 | {name: 'Germany', value: 'DE'}, 83 | {name: 'Ghana', value: 'GH'}, 84 | {name: 'Gibraltar', value: 'GI'}, 85 | {name: 'Greece', value: 'GR'}, 86 | {name: 'Greenland', value: 'GL'}, 87 | {name: 'Grenada', value: 'GD'}, 88 | {name: 'Guadeloupe', value: 'GP'}, 89 | {name: 'Guam', value: 'GU'}, 90 | {name: 'Guatemala', value: 'GT'}, 91 | {name: 'Guernsey', value: 'GG'}, 92 | {name: 'Guinea', value: 'GN'}, 93 | {name: 'Guinea-Bissau', value: 'GW'}, 94 | {name: 'Guyana', value: 'GY'}, 95 | {name: 'Haiti', value: 'HT'}, 96 | {name: 'Heard Island and Mcdonald Islands', value: 'HM'}, 97 | {name: 'Holy See (Vatican City State)', value: 'VA'}, 98 | {name: 'Honduras', value: 'HN'}, 99 | {name: 'Hong Kong', value: 'HK'}, 100 | {name: 'Hungary', value: 'HU'}, 101 | {name: 'Iceland', value: 'IS'}, 102 | {name: 'India', value: 'IN'}, 103 | {name: 'Indonesia', value: 'ID'}, 104 | {name: 'Iran, Islamic Republic Of', value: 'IR'}, 105 | {name: 'Iraq', value: 'IQ'}, 106 | {name: 'Ireland', value: 'IE'}, 107 | {name: 'Isle of Man', value: 'IM'}, 108 | {name: 'Israel', value: 'IL'}, 109 | {name: 'Italy', value: 'IT'}, 110 | {name: 'Jamaica', value: 'JM'}, 111 | {name: 'Japan', value: 'JP'}, 112 | {name: 'Jersey', value: 'JE'}, 113 | {name: 'Jordan', value: 'JO'}, 114 | {name: 'Kazakhstan', value: 'KZ'}, 115 | {name: 'Kenya', value: 'KE'}, 116 | {name: 'Kiribati', value: 'KI'}, 117 | {name: 'Korea, Democratic People\'S Republic of', value: 'KP'}, 118 | {name: 'Korea, Republic of', value: 'KR'}, 119 | {name: 'Kuwait', value: 'KW'}, 120 | {name: 'Kyrgyzstan', value: 'KG'}, 121 | {name: 'Lao People\'S Democratic Republic', value: 'LA'}, 122 | {name: 'Latvia', value: 'LV'}, 123 | {name: 'Lebanon', value: 'LB'}, 124 | {name: 'Lesotho', value: 'LS'}, 125 | {name: 'Liberia', value: 'LR'}, 126 | {name: 'Libyan Arab Jamahiriya', value: 'LY'}, 127 | {name: 'Liechtenstein', value: 'LI'}, 128 | {name: 'Lithuania', value: 'LT'}, 129 | {name: 'Luxembourg', value: 'LU'}, 130 | {name: 'Macao', value: 'MO'}, 131 | {name: 'Macedonia, The Former Yugoslav Republic of', value: 'MK'}, 132 | {name: 'Madagascar', value: 'MG'}, 133 | {name: 'Malawi', value: 'MW'}, 134 | {name: 'Malaysia', value: 'MY'}, 135 | {name: 'Maldives', value: 'MV'}, 136 | {name: 'Mali', value: 'ML'}, 137 | {name: 'Malta', value: 'MT'}, 138 | {name: 'Marshall Islands', value: 'MH'}, 139 | {name: 'Martinique', value: 'MQ'}, 140 | {name: 'Mauritania', value: 'MR'}, 141 | {name: 'Mauritius', value: 'MU'}, 142 | {name: 'Mayotte', value: 'YT'}, 143 | {name: 'Mexico', value: 'MX'}, 144 | {name: 'Micronesia, Federated States of', value: 'FM'}, 145 | {name: 'Moldova, Republic of', value: 'MD'}, 146 | {name: 'Monaco', value: 'MC'}, 147 | {name: 'Mongolia', value: 'MN'}, 148 | {name: 'Montserrat', value: 'MS'}, 149 | {name: 'Morocco', value: 'MA'}, 150 | {name: 'Mozambique', value: 'MZ'}, 151 | {name: 'Myanmar', value: 'MM'}, 152 | {name: 'Namibia', value: 'NA'}, 153 | {name: 'Nauru', value: 'NR'}, 154 | {name: 'Nepal', value: 'NP'}, 155 | {name: 'Netherlands', value: 'NL'}, 156 | {name: 'Netherlands Antilles', value: 'AN'}, 157 | {name: 'New Caledonia', value: 'NC'}, 158 | {name: 'New Zealand', value: 'NZ'}, 159 | {name: 'Nicaragua', value: 'NI'}, 160 | {name: 'Niger', value: 'NE'}, 161 | {name: 'Nigeria', value: 'NG'}, 162 | {name: 'Niue', value: 'NU'}, 163 | {name: 'Norfolk Island', value: 'NF'}, 164 | {name: 'Northern Mariana Islands', value: 'MP'}, 165 | {name: 'Norway', value: 'NO'}, 166 | {name: 'Oman', value: 'OM'}, 167 | {name: 'Pakistan', value: 'PK'}, 168 | {name: 'Palau', value: 'PW'}, 169 | {name: 'Palestinian Territory, Occupied', value: 'PS'}, 170 | {name: 'Panama', value: 'PA'}, 171 | {name: 'Papua New Guinea', value: 'PG'}, 172 | {name: 'Paraguay', value: 'PY'}, 173 | {name: 'Peru', value: 'PE'}, 174 | {name: 'Philippines', value: 'PH'}, 175 | {name: 'Pitcairn', value: 'PN'}, 176 | {name: 'Poland', value: 'PL'}, 177 | {name: 'Portugal', value: 'PT'}, 178 | {name: 'Puerto Rico', value: 'PR'}, 179 | {name: 'Qatar', value: 'QA'}, 180 | {name: 'Reunion', value: 'RE'}, 181 | {name: 'Romania', value: 'RO'}, 182 | {name: 'Russian Federation', value: 'RU'}, 183 | {name: 'RWANDA', value: 'RW'}, 184 | {name: 'Saint Helena', value: 'SH'}, 185 | {name: 'Saint Kitts and Nevis', value: 'KN'}, 186 | {name: 'Saint Lucia', value: 'LC'}, 187 | {name: 'Saint Pierre and Miquelon', value: 'PM'}, 188 | {name: 'Saint Vincent and the Grenadines', value: 'VC'}, 189 | {name: 'Samoa', value: 'WS'}, 190 | {name: 'San Marino', value: 'SM'}, 191 | {name: 'Sao Tome and Principe', value: 'ST'}, 192 | {name: 'Saudi Arabia', value: 'SA'}, 193 | {name: 'Senegal', value: 'SN'}, 194 | {name: 'Serbia and Montenegro', value: 'CS'}, 195 | {name: 'Seychelles', value: 'SC'}, 196 | {name: 'Sierra Leone', value: 'SL'}, 197 | {name: 'Singapore', value: 'SG'}, 198 | {name: 'Slovakia', value: 'SK'}, 199 | {name: 'Slovenia', value: 'SI'}, 200 | {name: 'Solomon Islands', value: 'SB'}, 201 | {name: 'Somalia', value: 'SO'}, 202 | {name: 'South Africa', value: 'ZA'}, 203 | {name: 'South Georgia and the South Sandwich Islands', value: 'GS'}, 204 | {name: 'Spain', value: 'ES'}, 205 | {name: 'Sri Lanka', value: 'LK'}, 206 | {name: 'Sudan', value: 'SD'}, 207 | {name: 'Suriname', value: 'SR'}, 208 | {name: 'Svalbard and Jan Mayen', value: 'SJ'}, 209 | {name: 'Swaziland', value: 'SZ'}, 210 | {name: 'Sweden', value: 'SE'}, 211 | {name: 'Switzerland', value: 'CH'}, 212 | {name: 'Syrian Arab Republic', value: 'SY'}, 213 | {name: 'Taiwan, Province of China', value: 'TW'}, 214 | {name: 'Tajikistan', value: 'TJ'}, 215 | {name: 'Tanzania, United Republic of', value: 'TZ'}, 216 | {name: 'Thailand', value: 'TH'}, 217 | {name: 'Timor-Leste', value: 'TL'}, 218 | {name: 'Togo', value: 'TG'}, 219 | {name: 'Tokelau', value: 'TK'}, 220 | {name: 'Tonga', value: 'TO'}, 221 | {name: 'Trinidad and Tobago', value: 'TT'}, 222 | {name: 'Tunisia', value: 'TN'}, 223 | {name: 'Turkey', value: 'TR'}, 224 | {name: 'Turkmenistan', value: 'TM'}, 225 | {name: 'Turks and Caicos Islands', value: 'TC'}, 226 | {name: 'Tuvalu', value: 'TV'}, 227 | {name: 'Uganda', value: 'UG'}, 228 | {name: 'Ukraine', value: 'UA'}, 229 | {name: 'United Arab Emirates', value: 'AE'}, 230 | {name: 'United Kingdom', value: 'GB'}, 231 | {name: 'United States', value: 'US'}, 232 | {name: 'United States Minor Outlying Islands', value: 'UM'}, 233 | {name: 'Uruguay', value: 'UY'}, 234 | {name: 'Uzbekistan', value: 'UZ'}, 235 | {name: 'Vanuatu', value: 'VU'}, 236 | {name: 'Venezuela', value: 'VE'}, 237 | {name: 'Viet Nam', value: 'VN'}, 238 | {name: 'Virgin Islands, British', value: 'VG'}, 239 | {name: 'Virgin Islands, U.S.', value: 'VI'}, 240 | {name: 'Wallis and Futuna', value: 'WF'}, 241 | {name: 'Western Sahara', value: 'EH'}, 242 | {name: 'Yemen', value: 'YE'}, 243 | {name: 'Zambia', value: 'ZM'}, 244 | {name: 'Zimbabwe', value: 'ZW'} 245 | ]; 246 | 247 | export const fontStacks = [ 248 | { 249 | name: 'Helvetica', 250 | value: 'helvetica', 251 | }, 252 | { 253 | type: 'group', 254 | name: 'Sans serif', 255 | items: [ 256 | { name: 'Roboto', value: 'Roboto', 'data-stack': 'Roboto, sans-serif' } 257 | ] 258 | }, 259 | { 260 | type: 'group', 261 | name: 'Serif', 262 | items: [ 263 | { name: 'Playfair Display', value: 'Playfair Display', 'data-stack': '"Playfair Display", serif' } 264 | ] 265 | }, 266 | { 267 | type: 'group', 268 | name: 'Cursive', 269 | items: [ 270 | { name: 'Monoton', value: 'Monoton', 'data-stack': 'Monoton, cursive' }, 271 | { name: 'Gloria Hallelujah', value: 'Gloria Hallelujah', 'data-stack': '"Gloria Hallelujah", cursive' } 272 | ] 273 | }, 274 | { 275 | type: 'group', 276 | name: 'Monospace', 277 | items: [ 278 | { name: 'VT323', value: 'VT323', 'data-stack': 'VT323, monospace' } 279 | ] 280 | } 281 | ]; 282 | 283 | export const friends = [ 284 | { name: 'Annie Cruz', value: 'annie.cruz', photo: 'https://randomuser.me/api/portraits/women/60.jpg' }, 285 | { name: 'Eli Shelton', disabled: true, value: 'eli.shelton', photo: 'https://randomuser.me/api/portraits/men/7.jpg' }, 286 | { name: 'Loretta Rogers', value: 'loretta.rogers', photo: 'https://randomuser.me/api/portraits/women/51.jpg' }, 287 | { name: 'Lloyd Fisher', value: 'lloyd.fisher', photo: 'https://randomuser.me/api/portraits/men/34.jpg' }, 288 | { name: 'Tiffany Gonzales', value: 'tiffany.gonzales', photo: 'https://randomuser.me/api/portraits/women/71.jpg' }, 289 | { name: 'Charles Hardy', value: 'charles.hardy', photo: 'https://randomuser.me/api/portraits/men/12.jpg' }, 290 | { name: 'Rudolf Wilson', value: 'rudolf.wilson', photo: 'https://randomuser.me/api/portraits/men/40.jpg' }, 291 | { name: 'Emerald Hensley', value: 'emerald.hensley', photo: 'https://randomuser.me/api/portraits/women/1.jpg' }, 292 | { name: 'Lorena McCoy', value: 'lorena.mccoy', photo: 'https://randomuser.me/api/portraits/women/70.jpg' }, 293 | { name: 'Alicia Lamb', value: 'alicia.lamb', photo: 'https://randomuser.me/api/portraits/women/22.jpg' }, 294 | { name: 'Maria Waters', value: 'maria.waters', photo: 'https://randomuser.me/api/portraits/women/82.jpg' }, 295 | ]; 296 | -------------------------------------------------------------------------------- /__tests__/flattenOptions.test.js: -------------------------------------------------------------------------------- 1 | import FlattenOptions from '../src/lib/flattenOptions'; 2 | 3 | describe('Unit test for FlattenOptions function', () => { 4 | const groupedOptions = [ 5 | { 6 | "type": "group", 7 | "name": "Cursive", 8 | "items": [ 9 | { 10 | "name": "Monoton", 11 | "value": "Monoton", 12 | }, 13 | ] 14 | }, 15 | { 16 | "name": "Gloria Hallelujah", 17 | "value": "Gloria Hallelujah", 18 | }, 19 | ]; 20 | 21 | const flattenOptions = FlattenOptions(groupedOptions); 22 | 23 | test('Has correct items', () => { 24 | expect(flattenOptions).toHaveLength(2); 25 | }); 26 | 27 | test('First item should be a group', () => { 28 | expect('group' in flattenOptions[0]).toEqual(true); 29 | }); 30 | 31 | test('Second item should not be a group', () => { 32 | expect('group' in flattenOptions[1]).toEqual(false); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /__tests__/getDisplayValue.test.js: -------------------------------------------------------------------------------- 1 | import getDisplayValue from '../src/lib/getDisplayValue'; 2 | import { friends } from './data'; 3 | 4 | describe('Unit test for getDisplayValue function', () => { 5 | test('Can get name from option by value', () => { 6 | const option = friends[Math.floor(Math.random() * friends.length)]; 7 | const secondOption = 8 | friends[Math.floor(Math.random() * friends.length)]; 9 | 10 | expect(getDisplayValue(option)).toEqual(option.name); 11 | expect(getDisplayValue([option, secondOption])).toStrictEqual( 12 | `${option.name}, ${secondOption.name}`, 13 | ); 14 | expect(getDisplayValue('foo')).toEqual(''); 15 | expect(getDisplayValue([{ value: 'fake-option' }])).toEqual(''); 16 | }); 17 | 18 | test('Can return default values', () => { 19 | expect(getDisplayValue(null, friends)).toEqual(friends[0].name); 20 | expect(getDisplayValue(null, [{ value: 'fake-option' }])).toEqual(''); 21 | expect(getDisplayValue(null, [])).toEqual(''); 22 | expect(getDisplayValue(null, friends, 'Placeholder')).toEqual(''); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /__tests__/getOption.test.js: -------------------------------------------------------------------------------- 1 | import getOption from '../src/lib/getOption'; 2 | import { friends } from './data'; 3 | 4 | describe('Unit test for getOption function', () => { 5 | test('Can get option by value', () => { 6 | const optionToFind = 7 | friends[Math.floor(Math.random() * friends.length)]; 8 | 9 | expect(getOption(optionToFind.value, friends)).toStrictEqual( 10 | optionToFind, 11 | ); 12 | expect(getOption('foo', friends)).toEqual(null); 13 | }); 14 | 15 | test('Can get option by value (multiple)', () => { 16 | const option1 = friends[0]; 17 | const option2 = friends[1]; 18 | 19 | expect( 20 | getOption([option1.value, option2.value], friends), 21 | ).toStrictEqual([option1, option2]); 22 | }); 23 | 24 | test('Return null if no default value can be found', () => { 25 | const option1 = { ...friends[0], disabled: true }; 26 | const option2 = { ...friends[1], disabled: true }; 27 | 28 | expect(getOption(null, [option1, option2])).toEqual(null); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /__tests__/getValue.test.js: -------------------------------------------------------------------------------- 1 | import { friends } from './data'; 2 | import getValue from '../src/lib/getValue'; 3 | 4 | describe('Unit test for getValue function', () => { 5 | test('Can get value from option', () => { 6 | const friend = friends[0]; 7 | 8 | expect(getValue(friend)).toStrictEqual(friend.value); 9 | }); 10 | 11 | test('Non option should return null', () => { 12 | expect(getValue({ name: 'Name' })).toStrictEqual(null); 13 | expect(getValue()).toStrictEqual(null); 14 | }); 15 | 16 | test('Can get value from multiple options', () => { 17 | const friend1 = friends[0]; 18 | const friend2 = friends[1]; 19 | 20 | expect(getValue([friend1, friend2])).toStrictEqual([ 21 | friend1.value, 22 | friend2.value, 23 | ]); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /__tests__/groupOptions.test.js: -------------------------------------------------------------------------------- 1 | import GroupOptions from '../src/lib/groupOptions'; 2 | 3 | describe('Unit test for GroupOptions function', () => { 4 | const flattenOptions = [ 5 | { 6 | "name": "Monoton", 7 | "value": "monoton", 8 | "group": "Cursive" 9 | }, 10 | { 11 | "name": "Helvetica", 12 | "value": "helvetica", 13 | }, 14 | { 15 | "name": "Gloria Hallelujah", 16 | "value": "gloria", 17 | "group": "Cursive" 18 | }, 19 | ]; 20 | 21 | const groupedOptions = GroupOptions(flattenOptions); 22 | 23 | test('Has correct amount of items', () => { 24 | expect(groupedOptions.length).toEqual(2); 25 | }); 26 | 27 | test('First item should be a group', () => { 28 | expect(groupedOptions[0].type).toEqual('group'); 29 | expect(groupedOptions[0].name).toEqual('Cursive'); 30 | expect('items' in groupedOptions[0]).toEqual(true); 31 | }); 32 | 33 | test('Group should have correct amount of items', () => { 34 | expect(groupedOptions[0].items.length).toEqual(2); 35 | }); 36 | 37 | test('Last item should not be a group', () => { 38 | expect('items' in groupedOptions[1]).toEqual(false); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /__tests__/highlight.test.js: -------------------------------------------------------------------------------- 1 | import highlight from '../src/lib/highlight'; 2 | import { friends } from './data'; 3 | 4 | describe('Unit test for highlight function', () => { 5 | test('Can move down', () => { 6 | expect(highlight(-1, 'down', friends)).toEqual(0); 7 | }); 8 | 9 | test('Can move up', () => { 10 | expect(highlight(3, 'up', friends)).toEqual(2); 11 | }); 12 | 13 | test('Can reverse to end or beginning', () => { 14 | expect(highlight(-1, 'up', friends)).toEqual(friends.length - 1); 15 | expect(highlight(friends.length - 1, 'down', friends)).toEqual(0); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /__tests__/isSelected.test.js: -------------------------------------------------------------------------------- 1 | import isSelected from '../src/lib/isSelected'; 2 | 3 | const option = { value: 'foo', name: 'Foo' }; 4 | const secondOption = { value: 'bar', name: 'Bar' }; 5 | 6 | describe('Unit test for isSelected function', () => { 7 | test('Should be true', () => { 8 | const str = isSelected(option, option); 9 | const arr = isSelected(option, [option, secondOption]); 10 | 11 | expect(str).toEqual(true); 12 | expect(arr).toEqual(true); 13 | }); 14 | 15 | test('Should be false', () => { 16 | const str = isSelected(option, secondOption); 17 | const arr = isSelected(option, [secondOption]); 18 | const arr2 = isSelected(option, []); 19 | 20 | expect(str).toEqual(false); 21 | expect(arr).toEqual(false); 22 | expect(arr2).toEqual(false); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /__tests__/reduce.test.js: -------------------------------------------------------------------------------- 1 | import flattenOptions from '../src/lib/flattenOptions'; 2 | import { countries } from './data'; 3 | import reduce from '../src/lib/reduce'; 4 | 5 | const options = flattenOptions(countries); 6 | 7 | describe('Unit test for reduce function', () => { 8 | test('Can search', () => { 9 | const middleware = [ 10 | (items, query) => items.filter((option) => option.name.indexOf(query) >= 0), 11 | (items) => items.slice(0, 3), 12 | ]; 13 | 14 | const filteredOptions = reduce(middleware, options, 'land'); 15 | 16 | expect(filteredOptions[0].value).toEqual('AX'); 17 | expect(filteredOptions.length).toEqual(3); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /__tests__/search.test.js: -------------------------------------------------------------------------------- 1 | import fuzzySearch from '../src/lib/fuzzySearch'; 2 | import flattenOptions from '../src/lib/flattenOptions'; 3 | import { countries, fontStacks } from './data'; 4 | 5 | const options = flattenOptions(countries); 6 | const fontOptions = flattenOptions(fontStacks); 7 | 8 | describe('Unit test for search function', () => { 9 | test('Can search', () => { 10 | const newOptions = fuzzySearch(options, 'swden'); 11 | const exactMatch = fuzzySearch(options, 'Italy'); 12 | const noOptions = fuzzySearch(options, 'foobar'); 13 | 14 | expect(newOptions.length).toEqual(1); 15 | expect(newOptions[0].name).toEqual('Sweden'); 16 | expect(exactMatch[0].name).toEqual('Italy'); 17 | expect(noOptions.length).toEqual(0); 18 | }); 19 | 20 | test('Can search groups', () => { 21 | const newOptions = fuzzySearch(fontOptions, 'Sans serif'); 22 | const noOptions = fuzzySearch(fontOptions, 'foobar'); 23 | 24 | expect(newOptions.length).toEqual(1); 25 | expect(noOptions.length).toEqual(0); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /__tests__/storybook.test.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import * as glob from 'glob'; 3 | 4 | import { describe, test, expect } from '@jest/globals'; 5 | import { composeStories } from '@storybook/react'; 6 | 7 | const compose = (entry) => { 8 | try { 9 | return composeStories(entry); 10 | } catch (e) { 11 | throw new Error( 12 | `There was an issue composing stories for the module: ${JSON.stringify(entry)}, ${e}` 13 | ); 14 | } 15 | }; 16 | 17 | function getAllStoryFiles() { 18 | // Place the glob you want to match your stories files 19 | const storyFiles = glob.sync( 20 | path.join(__dirname, '../stories/**/*.{stories,story}.{js,jsx,mjs,ts,tsx}') 21 | ); 22 | 23 | return storyFiles.map((filePath) => { 24 | const storyFile = require(filePath); 25 | return { filePath, storyFile }; 26 | }); 27 | } 28 | 29 | // Recreate similar options to Storyshots. Place your configuration below 30 | const options = { 31 | suite: 'Storybook Tests', 32 | storyKindRegex: /^.*?DontTest$/, 33 | storyNameRegex: /UNSET/, 34 | snapshotsDirName: '__snapshots__', 35 | snapshotExtension: '.storyshot', 36 | }; 37 | 38 | describe(options.suite, () => { 39 | getAllStoryFiles().forEach(({ storyFile, componentName }) => { 40 | const meta = storyFile.default; 41 | const title = meta.title || componentName; 42 | 43 | if (options.storyKindRegex.test(title) || meta.parameters?.storyshots?.disable) { 44 | // Skip component tests if they are disabled 45 | return; 46 | } 47 | 48 | describe(title, () => { 49 | const stories = Object.entries(compose(storyFile)) 50 | .map(([name, story]) => ({ name, story })) 51 | .filter(({ name, story }) => { 52 | // Implements a filtering mechanism to avoid running stories that are disabled via parameters or that match a specific regex mirroring the default behavior of Storyshots. 53 | return !options.storyNameRegex.test(name) && !story.parameters.storyshots?.disable; 54 | }); 55 | 56 | if (stories.length <= 0) { 57 | throw new Error( 58 | `No stories found for this module: ${title}. Make sure there is at least one valid story for this module, without a disable parameter, or add parameters.storyshots.disable in the default export of this file.` 59 | ); 60 | } 61 | 62 | stories.forEach(({ name, story }) => { 63 | // Instead of not running the test, you can create logic to skip it, flagging it accordingly in the test results. 64 | const testFn = story.parameters.storyshots?.skip ? test.skip : test; 65 | 66 | testFn(name, async () => { 67 | await story.run(); 68 | // Ensures a consistent snapshot by waiting for the component to render by adding a delay of 1 ms before taking the snapshot. 69 | await new Promise((resolve) => setTimeout(resolve, 1)); 70 | expect(document.body.firstChild).toMatchSnapshot(); 71 | }); 72 | }); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /__tests__/updateOption.test.js: -------------------------------------------------------------------------------- 1 | import updateOption from '../src/lib/updateOption'; 2 | import { friends } from './data'; 3 | 4 | describe('Unit test for updateOption function', () => { 5 | test('Can change option', () => { 6 | const friend = friends[0]; 7 | 8 | expect(updateOption(friend, null).value).toEqual(friend.value); 9 | expect(updateOption(friend, friend[1]).value).toEqual(friend.value); 10 | }); 11 | 12 | test('Can update multiple options', () => { 13 | const friend1 = friends[0]; 14 | const friend2 = friends[1]; 15 | 16 | expect(updateOption(friend1, null, true)).toStrictEqual([friend1]); 17 | expect(updateOption([friend1], null, true)).toStrictEqual([friend1]); 18 | expect(updateOption(friend1, [friend2], true)).toStrictEqual([ 19 | friend2, 20 | friend1, 21 | ]); 22 | expect(updateOption(friend1, friend2, true)).toStrictEqual([ 23 | friend2, 24 | friend1, 25 | ]); 26 | expect(updateOption(friend1, [friend1], true)).toStrictEqual([]); 27 | }); 28 | 29 | test('Return old value if no new one', () => { 30 | const friend = friends[0]; 31 | 32 | expect(updateOption(null, friend)).toStrictEqual(friend); 33 | expect(updateOption(null, [friend], true)).toStrictEqual([friend]); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["transform-react-remove-prop-types"], 3 | "presets": [ 4 | "@babel/preset-env", 5 | ["@babel/preset-react", {"runtime": "automatic"}] 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /doctor-storybook.log: -------------------------------------------------------------------------------- 1 | 🩺 The doctor is checking the health of your Storybook.. 2 | ╭ Incompatible packages found ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ 3 | │ │ 4 | │ The following packages are incompatible with Storybook 8.3.2 as they depend on different major versions of Storybook packages: │ 5 | │ - @storybook/addon-storyshots@7.6.17 │ 6 | │ Repo: https://github.com/storybookjs/storybook/tree/next/code/addons/storyshots-core │ 7 | │ - @storybook/addons@7.6.17 │ 8 | │ Repo: https://github.com/storybookjs/storybook/tree/next/code/deprecated/addons │ 9 | │ │ 10 | │ │ 11 | │ Please consider updating your packages or contacting the maintainers for compatibility details. │ 12 | │ For more on Storybook 8 compatibility, see the linked GitHub issue: │ 13 | │ https://github.com/storybookjs/storybook/issues/26031 │ 14 | │ │ 15 | ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ 16 | 17 | You can always recheck the health of your project by running: 18 | npx storybook doctor 19 | 20 | Full logs are available in /Users/tobiasbleckert/Utveckling/www/react-select-search/doctor-storybook.log 21 | 22 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | collectCoverageFrom: ['src/**/{!(types.js|index.js),}.{js,jsx}'], 4 | testMatch: ['/__tests__/*.test.{js,jsx}'], 5 | moduleNameMapper: { 6 | '\\.(css|less)$': 'identity-obj-proxy', 7 | }, 8 | testEnvironment: 'jest-environment-jsdom', 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-select-search", 3 | "version": "4.1.8", 4 | "description": "Lightweight select component for React", 5 | "source": "src/index.js", 6 | "main": "dist/cjs/index.js", 7 | "module": "dist/esm/index.js", 8 | "types": "src/index.d.ts", 9 | "targets": { 10 | "types": false 11 | }, 12 | "sideEffects": false, 13 | "scripts": { 14 | "lint": "eslint src --ext .js --ext .jsx", 15 | "test": "npm run build && size-limit && NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest", 16 | "test:watch": "npm test -- --watch", 17 | "test:coverage": "npm test -- --coverage --silent", 18 | "coveralls": "npm test:coverage && cat ./coverage/lcov.info | coveralls", 19 | "start": "storybook dev -p 6006", 20 | "build": "parcel build --no-cache", 21 | "storybook": "storybook dev -p 6006", 22 | "build-storybook": "storybook build --output-dir public", 23 | "size": "size-limit", 24 | "pub": "npm run build && npm publish", 25 | "eslint": "eslint src --ext .jsx --ext .js" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/tbleckert/react-select-search.git" 30 | }, 31 | "keywords": [ 32 | "react", 33 | "select", 34 | "js", 35 | "search", 36 | "react-component" 37 | ], 38 | "author": "Tobias Bleckert (hola@tobiasbleckert.se)", 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://github.com/tbleckert/react-select-search/issues" 42 | }, 43 | "homepage": "https://github.com/tbleckert/react-select-search", 44 | "size-limit": [ 45 | { 46 | "path": "dist/esm/index.js", 47 | "limit": "3 kB" 48 | } 49 | ], 50 | "peerDependencies": { 51 | "prop-types": "^15.8.1", 52 | "react": "^18.0.1 || ^17.0.1", 53 | "react-dom": "^18.0.1 || ^17.0.1" 54 | }, 55 | "devDependencies": { 56 | "@babel/core": "^7.25.2", 57 | "@babel/preset-env": "^7.25.4", 58 | "@babel/preset-react": "^7.24.7", 59 | "@chromatic-com/storybook": "^2.0.2", 60 | "@jest/globals": "^29.7.0", 61 | "@size-limit/preset-small-lib": "^11.1.5", 62 | "@storybook/addon-actions": "^8.3.2", 63 | "@storybook/addon-essentials": "^8.3.2", 64 | "@storybook/addon-links": "^8.3.2", 65 | "@storybook/addon-webpack5-compiler-babel": "^3.0.3", 66 | "@storybook/addons": "^7.6.17", 67 | "@storybook/react": "^8.3.2", 68 | "@storybook/react-webpack5": "^8.3.2", 69 | "babel-jest": "^29.7.0", 70 | "babel-plugin-transform-react-remove-prop-types": "^0.4.24", 71 | "coveralls": "^3.1.1", 72 | "eslint": "^9.11.1", 73 | "eslint-config-prettier": "^9.1.0", 74 | "eslint-plugin-react": "^7.36.1", 75 | "identity-obj-proxy": "^3.0.0", 76 | "jest": "^29.7.0", 77 | "jest-environment-jsdom": "^29.7.0", 78 | "jest-image-snapshot": "^6.4.0", 79 | "parcel": "^2.12.0", 80 | "prettier": "^3.3.3", 81 | "prop-types": "^15.8.1", 82 | "react": "^18.3.1", 83 | "react-dom": "^18.3.1", 84 | "react-test-renderer": "^18.3.1", 85 | "react-transition-group": "^4.4.5", 86 | "size-limit": "^11.1.5", 87 | "storybook": "^8.3.2", 88 | "storybook-dark-mode": "^4.0.2" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/SelectSearch.jsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useEffect, useRef, useState } from "react"; 2 | import PropTypes from 'prop-types'; 3 | import useSelect from './useSelect'; 4 | import classes from './lib/classes'; 5 | import Options from './components/Options'; 6 | 7 | const SelectSearch = forwardRef( 8 | ( 9 | { 10 | disabled, 11 | placeholder, 12 | multiple, 13 | search, 14 | autoFocus, 15 | autoComplete = 'on', 16 | id, 17 | closeOnSelect = true, 18 | className = 'select-search', 19 | renderValue, 20 | renderOption, 21 | renderGroupHeader, 22 | fuzzySearch = true, 23 | emptyMessage, 24 | value, 25 | debounce = 250, 26 | printOptions = 'auto', 27 | options = [], 28 | ...hookProps 29 | }, 30 | ref, 31 | ) => { 32 | const selectRef = useRef(null); 33 | const cls = (classNames) => classes(classNames, className); 34 | const [controlledValue, setControlledValue] = useState(value); 35 | const [snapshot, valueProps, optionProps] = useSelect({ 36 | value: controlledValue, 37 | placeholder, 38 | multiple, 39 | search, 40 | closeOnSelect: closeOnSelect && !multiple, 41 | useFuzzySearch: fuzzySearch, 42 | options, 43 | printOptions, 44 | debounce, 45 | ...hookProps, 46 | }); 47 | const { highlighted, value: snapValue, fetching, focus } = snapshot; 48 | 49 | const props = { 50 | ...valueProps, 51 | autoFocus, 52 | autoComplete, 53 | disabled, 54 | }; 55 | 56 | useEffect(() => { 57 | const { current } = selectRef; 58 | 59 | if (current) { 60 | const val = Array.isArray(snapValue) ? snapValue[0] : snapValue; 61 | const selected = current.querySelector( 62 | highlighted > -1 63 | ? `[data-index="${highlighted}"]` 64 | : `[value="${encodeURIComponent(val)}"]`, 65 | ); 66 | 67 | if (selected) { 68 | const rect = current.getBoundingClientRect(); 69 | const selectedRect = selected.getBoundingClientRect(); 70 | 71 | current.scrollTop = 72 | selected.offsetTop - 73 | rect.height / 2 + 74 | selectedRect.height / 2; 75 | } 76 | } 77 | }, [snapValue, highlighted, selectRef.current]); 78 | 79 | useEffect(() => setControlledValue(value), [value]); 80 | 81 | return ( 82 |
93 | {(!multiple || placeholder || search) && ( 94 |
95 | {renderValue && 96 | renderValue(props, snapshot, cls('input'))} 97 | {!renderValue && ( 98 | 99 | )} 100 |
101 | )} 102 |
e.preventDefault()} 106 | > 107 | {snapshot.options.length > 0 && ( 108 | 117 | )} 118 | {!snapshot.options.length && ( 119 |
    120 | {!snapshot.options.length && emptyMessage && ( 121 |
  • 122 | {emptyMessage} 123 |
  • 124 | )} 125 |
126 | )} 127 |
128 |
129 | ); 130 | }, 131 | ); 132 | 133 | SelectSearch.propTypes = { 134 | // Data 135 | options: PropTypes.arrayOf( 136 | PropTypes.shape({ 137 | name: PropTypes.string.isRequired, 138 | value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) 139 | .isRequired, 140 | }), 141 | ), 142 | getOptions: PropTypes.func, 143 | filterOptions: PropTypes.arrayOf(PropTypes.func), 144 | fuzzySearch: PropTypes.bool, 145 | value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 146 | defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 147 | 148 | // Interaction 149 | multiple: PropTypes.bool, 150 | search: PropTypes.bool, 151 | disabled: PropTypes.bool, 152 | closeOnSelect: PropTypes.bool, 153 | debounce: PropTypes.number, 154 | 155 | // Attributes 156 | placeholder: PropTypes.string, 157 | id: PropTypes.string, 158 | autoComplete: PropTypes.string, 159 | autoFocus: PropTypes.bool, 160 | 161 | // Design 162 | className: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), 163 | 164 | // Renderers 165 | renderOption: PropTypes.func, 166 | renderGroupHeader: PropTypes.func, 167 | renderValue: PropTypes.func, 168 | emptyMessage: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), 169 | 170 | // Events 171 | onChange: PropTypes.func, 172 | onFocus: PropTypes.func, 173 | onBlur: PropTypes.func, 174 | }; 175 | 176 | SelectSearch.displayName = 'SelectSearch'; 177 | 178 | export default SelectSearch; 179 | -------------------------------------------------------------------------------- /src/components/Option.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function Option({ 4 | optionProps, 5 | highlighted, 6 | selected, 7 | option, 8 | cls, 9 | renderOption, 10 | disabled, 11 | }) { 12 | const props = { 13 | ...optionProps, 14 | value: encodeURIComponent(option.value), 15 | disabled, 16 | }; 17 | const className = cls({ 18 | option: true, 19 | 'is-selected': selected, 20 | 'is-highlighted': highlighted, 21 | }); 22 | 23 | return ( 24 |
  • 25 | {renderOption && 26 | renderOption( 27 | props, 28 | option, 29 | { selected, highlighted }, 30 | className, 31 | )} 32 | {!renderOption && ( 33 | 36 | )} 37 |
  • 38 | ); 39 | } 40 | 41 | export default Option; 42 | -------------------------------------------------------------------------------- /src/components/Options.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Option from './Option'; 3 | import isSelected from '../lib/isSelected'; 4 | 5 | function Options(props) { 6 | const { 7 | options, 8 | cls, 9 | renderOption, 10 | renderGroupHeader, 11 | optionProps, 12 | snapshot, 13 | disabled, 14 | } = props; 15 | 16 | return ( 17 |
      18 | {options.map((o) => { 19 | if (o.type === 'group') { 20 | return ( 21 |
    • 22 |
      23 |
      24 | {renderGroupHeader 25 | ? renderGroupHeader(o.name) 26 | : o.name} 27 |
      28 | 29 |
      30 |
    • 31 | ); 32 | } 33 | 34 | return ( 35 |
    48 | ); 49 | } 50 | 51 | export default Options; 52 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FunctionComponent, 3 | Component, 4 | Ref, 5 | ReactNode, 6 | MutableRefObject, 7 | } from 'react'; 8 | 9 | export type SelectSearchOption = { 10 | name: string; 11 | value?: string | number; 12 | type?: string; 13 | items?: SelectSearchOption[]; 14 | disabled?: boolean; 15 | [key: string]: any; 16 | }; 17 | 18 | export type SelectedOption = { 19 | name: string; 20 | value: string | number; 21 | index: number; 22 | disabled?: boolean; 23 | [key: string]: any; 24 | }; 25 | 26 | export type SelectedOptionValue = string | number; 27 | 28 | export type OptionSnapshot = { 29 | selected: boolean; 30 | highlighted: boolean; 31 | }; 32 | 33 | export type DomProps = { 34 | tabIndex: string; 35 | onMouseDown: (event: MouseEvent) => void; 36 | onKeyDown: (event: KeyboardEvent) => void; 37 | onKeyPress: (event: KeyboardEvent) => void; 38 | onBlur: (event: Event) => void; 39 | value: string; 40 | disabled: boolean; 41 | }; 42 | 43 | export type ValueProps = { 44 | tabIndex: string; 45 | readonly: boolean; 46 | onMouseDown: (event: MouseEvent) => void; 47 | onKeyDown: (event: KeyboardEvent) => void; 48 | onKeyUp: (event: KeyboardEvent) => void; 49 | onKeyPress: (event: KeyboardEvent) => void; 50 | onBlur: (event: Event) => void; 51 | value: string; 52 | disabled: boolean; 53 | }; 54 | 55 | export type Snapshot = { 56 | value: SelectedOptionValue; 57 | highlighted: boolean; 58 | options: SelectedOptionValue[]; 59 | disabled: boolean; 60 | displayValue: string; 61 | focus: boolean; 62 | search: string; 63 | searching: boolean; 64 | }; 65 | 66 | export type SelectSearchProps = { 67 | options: SelectSearchOption[]; 68 | defaultValue?: string | string[]; 69 | value?: string | string[]; 70 | multiple?: boolean; 71 | search?: boolean; 72 | disabled?: boolean; 73 | placeholder?: string; 74 | id?: string; 75 | autoComplete?: 'on' | 'off'; 76 | autoFocus?: boolean; 77 | className?: ((classes: string) => string) | string | { readonly [key: string]: string; }; 78 | onChange?: ( 79 | selectedValue: SelectedOptionValue | SelectedOptionValue[], 80 | selectedOption: SelectedOption | SelectedOption[], 81 | optionSnapshot: SelectSearchProps, 82 | ) => void; 83 | closeOnSelect?: boolean; 84 | renderOption?: ( 85 | domProps: DomProps, 86 | option: SelectedOption, 87 | snapshot: OptionSnapshot, 88 | className: string, 89 | ) => ReactNode; 90 | fuzzySearch?: boolean; 91 | filterOptions?: (( 92 | options: SelectSearchOption[], 93 | query: string, 94 | ) => SelectSearchOption[])[]; 95 | renderValue?: ( 96 | valueProps: ValueProps, 97 | snapshot: Snapshot, 98 | className: string, 99 | ) => ReactNode; 100 | renderGroupHeader?: (name: string) => string; 101 | getOptions?: (query: string) => Promise; 102 | debounce?: number; 103 | ref?: Ref; 104 | emptyMessage?: ReactNode | (() => ReactNode); 105 | onBlur: (event: Event) => void; 106 | onFocus: (event: Event) => void; 107 | }; 108 | 109 | export const SelectSearch: FunctionComponent; 110 | 111 | export function useSelect(Options: { 112 | defaultValue?: string | string[]; 113 | value?: string | string[]; 114 | multiple?: boolean; 115 | search?: boolean; 116 | options?: SelectSearchOption[]; 117 | onChange?: ( 118 | selectedValue: SelectedOptionValue | SelectedOptionValue[], 119 | selectedOption: SelectedOption | SelectedOption[], 120 | optionSnapshot: SelectSearchProps, 121 | ) => void; 122 | getOptions?: (query: string) => Promise; 123 | useFuzzySearch?: boolean; 124 | filterOptions?: (( 125 | options: SelectSearchOption[], 126 | query: string, 127 | ) => SelectSearchOption[])[]; 128 | allowEmpty?: boolean; 129 | closeOnSelect?: boolean; 130 | closable?: boolean; 131 | debounce?: number; 132 | }): [ 133 | Snapshot, 134 | { 135 | tabIndex: string; 136 | readOnly: boolean; 137 | onChange: ( 138 | selectedValue: SelectedOptionValue | SelectedOptionValue[], 139 | selectedOption: SelectedOption | SelectedOption[], 140 | optionSnapshot: SelectSearchProps, 141 | ) => void; 142 | disabled: boolean; 143 | onMouseDown: (event: MouseEvent) => void; 144 | onKeyDown: (event: KeyboardEvent) => void; 145 | onKeyUp: (event: KeyboardEvent) => void; 146 | onKeyPress: (event: KeyboardEvent) => void; 147 | onBlur: () => void; 148 | onFocus: () => void; 149 | ref: MutableRefObject; 150 | }, 151 | { 152 | tabIndex: string; 153 | onMouseDown: (event: MouseEvent) => void; 154 | onKeyDown: (event: KeyboardEvent) => void; 155 | onKeyPress: (event: KeyboardEvent) => void; 156 | onBlur: () => void; 157 | }, 158 | (value: string) => void, 159 | ]; 160 | 161 | export default SelectSearch; 162 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import SelectSearch from './SelectSearch'; 2 | export { default as useSelect } from './useSelect'; 3 | 4 | export default SelectSearch; 5 | -------------------------------------------------------------------------------- /src/lib/classes.js: -------------------------------------------------------------------------------- 1 | const isString = (str) => typeof str === 'string'; 2 | const getClassName = (str, className) => 3 | isString(className) ? `${className}-${str}` : className[str]; 4 | 5 | export default function classes(classNames, className) { 6 | if (isString(classNames)) return getClassName(classNames, className); 7 | 8 | return Object.entries(classNames) 9 | .filter(([cls, display]) => cls && display) 10 | .map(([cls]) => getClassName(cls, className)) 11 | .join(' '); 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/flattenOptions.js: -------------------------------------------------------------------------------- 1 | export default function flattenOptions(options) { 2 | let index = 0; 3 | 4 | return options.map((option) => { 5 | if (option.type === 'group') { 6 | return option.items.map((o) => ({ 7 | ...o, 8 | group: option.name, 9 | index: index++, 10 | })); 11 | } 12 | 13 | return { ...option, index: index++ }; 14 | }).flat(); 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/fuzzySearch.js: -------------------------------------------------------------------------------- 1 | function search(q, text) { 2 | const searchLength = q.length; 3 | const textLength = text.length; 4 | 5 | if (searchLength > textLength) { 6 | return false; 7 | } 8 | 9 | if (text.indexOf(q) >= 0) { 10 | return true; 11 | } 12 | 13 | mainLoop: for (let i = 0, j = 0; i < searchLength; i += 1) { 14 | const ch = q.charCodeAt(i); 15 | 16 | while (j < textLength) { 17 | if (text.charCodeAt(j++) === ch) { 18 | continue mainLoop; 19 | } 20 | } 21 | 22 | return false; 23 | } 24 | 25 | return true; 26 | } 27 | 28 | export default function fuzzySearch(options, query) { 29 | return !query.length 30 | ? options 31 | : options.filter((o) => 32 | search( 33 | query.toLowerCase(), 34 | `${o.name} ${o.group || ''}`.trim().toLowerCase(), 35 | ), 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/getDisplayValue.js: -------------------------------------------------------------------------------- 1 | export default function getDisplayValue(option, options, placeholder) { 2 | if (!option && !placeholder) { 3 | return options && options.length ? options[0].name || '' : ''; 4 | } 5 | 6 | const isMultiple = Array.isArray(option); 7 | 8 | if (!option && !isMultiple) { 9 | return ''; 10 | } 11 | 12 | return isMultiple 13 | ? option 14 | .map((o) => o.name) 15 | .filter(Boolean) 16 | .join(', ') 17 | : option.name || ''; 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/getOption.js: -------------------------------------------------------------------------------- 1 | import isSame from './isSame'; 2 | 3 | export default function getOption(value, options) { 4 | if (Array.isArray(value)) { 5 | return value 6 | .map((v) => options.find((o) => isSame(o.value, v))) 7 | .filter((o) => o); 8 | } 9 | 10 | return options.find((o) => isSame(o.value, value)) || null; 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/getValue.js: -------------------------------------------------------------------------------- 1 | export default function getValue(option) { 2 | if (!option) return null; 3 | 4 | if (Array.isArray(option)) { 5 | return option.filter(Boolean).map((o) => o.value); 6 | } 7 | 8 | return option.value || null; 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/groupOptions.js: -------------------------------------------------------------------------------- 1 | export default function groupOptions(options) { 2 | const nextOptions = []; 3 | 4 | options.forEach((option) => { 5 | if (option.group) { 6 | const group = nextOptions.findIndex((o) => o.type === 'group' && o.name === option.group); 7 | 8 | if (group >= 0) { 9 | nextOptions[group].items.push(option); 10 | } else { 11 | nextOptions.push({ 12 | items: [option], 13 | type: 'group', 14 | name: option.group, 15 | }); 16 | } 17 | } else { 18 | nextOptions.push(option); 19 | } 20 | }); 21 | 22 | return nextOptions; 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/highlight.js: -------------------------------------------------------------------------------- 1 | export default function highlight(current, dir, options) { 2 | const max = options.length - 1; 3 | let option = null; 4 | let i = -1; 5 | let newHighlighted = current; 6 | 7 | while (i++ <= max && (!option || option.disabled)) { 8 | newHighlighted = 9 | dir === 'down' ? newHighlighted + 1 : newHighlighted - 1; 10 | 11 | if (newHighlighted < 0) { 12 | newHighlighted = max; 13 | } else if (newHighlighted > max) { 14 | newHighlighted = 0; 15 | } 16 | 17 | option = options[newHighlighted]; 18 | } 19 | 20 | return newHighlighted; 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/isSame.js: -------------------------------------------------------------------------------- 1 | export default function isSame(v1, v2) { 2 | return String(v1) === String(v2); 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/isSelected.js: -------------------------------------------------------------------------------- 1 | export default function isSelected(option, selectedOption) { 2 | if (!selectedOption) return false; 3 | 4 | return Array.isArray(selectedOption) 5 | ? selectedOption.findIndex((o) => o.value === option.value) >= 0 6 | : selectedOption.value === option.value; 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/reduce.js: -------------------------------------------------------------------------------- 1 | export default function reduce(middleware, items, query) { 2 | return middleware.filter(Boolean) 3 | .reduce((data, cb) => cb(data, query), items) 4 | .map((item, i) => ({ ...item, index: i })); 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/toArray.js: -------------------------------------------------------------------------------- 1 | export default function toArray(value) { 2 | return Array.isArray(value) ? value : [value]; 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/updateOption.js: -------------------------------------------------------------------------------- 1 | import isSame from './isSame'; 2 | import toArray from './toArray'; 3 | 4 | export default function updateOption(newOption, oldOption, multiple) { 5 | if (!newOption) { 6 | return oldOption; 7 | } 8 | 9 | if (!multiple) { 10 | return newOption; 11 | } 12 | 13 | if (!oldOption) { 14 | return toArray(newOption); 15 | } 16 | 17 | const nextOption = [...toArray(oldOption)]; 18 | const newOptionIndex = nextOption.findIndex((o) => 19 | isSame(o.value, newOption.value), 20 | ); 21 | 22 | if (newOptionIndex >= 0) { 23 | nextOption.splice(newOptionIndex, 1); 24 | } else { 25 | nextOption.push(newOption); 26 | } 27 | 28 | return nextOption; 29 | } 30 | -------------------------------------------------------------------------------- /src/useHighlight.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import highlight from './lib/highlight'; 3 | 4 | export default function useHighlight(options, onSelect, ref) { 5 | const [highlighted, setHighlighted] = useState(-1); 6 | 7 | return [ 8 | { 9 | onKeyDown: (e) => { 10 | const key = e.key.replace('Arrow', '').toLowerCase(); 11 | 12 | if (key === 'down' || key === 'up') { 13 | e.preventDefault(); 14 | setHighlighted(highlight(highlighted, key, options)); 15 | } 16 | }, 17 | onKeyUp: (e) => { 18 | if (e.key === 'Escape') { 19 | e.preventDefault(); 20 | ref.current.blur(); 21 | } else if (e.key === 'Enter') { 22 | e.preventDefault(); 23 | 24 | if (options[highlighted]) { 25 | onSelect(options[highlighted].value); 26 | } 27 | } 28 | }, 29 | }, 30 | highlighted, 31 | setHighlighted, 32 | ]; 33 | } 34 | -------------------------------------------------------------------------------- /src/useOptions.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import flattenOptions from './lib/flattenOptions'; 3 | 4 | export default function useOptions( 5 | defaultOptions, 6 | getOptions, 7 | debounceTime, 8 | search, 9 | ) { 10 | const [options, setOptions] = useState(() => flattenOptions(defaultOptions)); 11 | const [fetching, setFetching] = useState(false); 12 | 13 | useEffect(() => { 14 | let timeout; 15 | 16 | if (!getOptions) { 17 | return; 18 | } 19 | 20 | timeout = setTimeout(() => { 21 | const optionsReq = getOptions(search, options); 22 | 23 | setFetching(true); 24 | 25 | Promise.resolve(optionsReq) 26 | .then((newOptions) => setOptions(flattenOptions(newOptions))) 27 | .finally(() => setFetching(false)); 28 | }, debounceTime); 29 | 30 | return () => { 31 | clearTimeout(timeout); 32 | }; 33 | }, [search]); 34 | 35 | useEffect(() => { 36 | setOptions(flattenOptions(defaultOptions)); 37 | }, [defaultOptions]); 38 | 39 | return [options, fetching]; 40 | } 41 | -------------------------------------------------------------------------------- /src/useSelect.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import getOption from './lib/getOption'; 3 | import updateOption from './lib/updateOption'; 4 | import getDisplayValue from './lib/getDisplayValue'; 5 | import getValue from './lib/getValue'; 6 | import groupOptions from './lib/groupOptions'; 7 | import fuzzySearch from './lib/fuzzySearch'; 8 | import reduce from './lib/reduce'; 9 | import useOptions from './useOptions'; 10 | import useHighlight from './useHighlight'; 11 | 12 | const nullCb = () => {}; 13 | 14 | export default function useSelect({ 15 | options: defaultOptions, 16 | defaultValue, 17 | value, 18 | multiple, 19 | search, 20 | onChange = nullCb, 21 | onFocus = nullCb, 22 | onBlur = nullCb, 23 | closeOnSelect = true, 24 | placeholder, 25 | getOptions, 26 | filterOptions, 27 | useFuzzySearch = true, 28 | debounce, 29 | }) { 30 | const ref = useRef(); 31 | const [option, setOption] = useState(null); 32 | const [q, setSearch] = useState(''); 33 | const [focus, setFocus] = useState(false); 34 | const [options, fetching] = useOptions( 35 | defaultOptions, 36 | getOptions, 37 | debounce, 38 | q, 39 | ); 40 | 41 | const onSelect = (v) => { 42 | const newOption = updateOption( 43 | getOption(decodeURIComponent(v), options), 44 | option, 45 | multiple, 46 | ); 47 | 48 | if (value === undefined) { 49 | setOption(newOption); 50 | } 51 | 52 | onChange(getValue(newOption), newOption); 53 | 54 | setTimeout(() => { 55 | if (ref.current && closeOnSelect) { 56 | ref.current.blur(); 57 | } 58 | }, 0); 59 | }; 60 | 61 | const middleware = [ 62 | useFuzzySearch ? fuzzySearch : null, 63 | ...(filterOptions ? filterOptions : []), 64 | ]; 65 | const filteredOptions = groupOptions(reduce(middleware, options, q)); 66 | const [keyHandlers, highlighted, setHighlighted] = useHighlight( 67 | filteredOptions, 68 | onSelect, 69 | ref, 70 | ); 71 | 72 | const snapshot = { 73 | search: q, 74 | focus, 75 | option, 76 | value: getValue(option), 77 | fetching, 78 | highlighted, 79 | options: filteredOptions, 80 | displayValue: getDisplayValue(option, options, placeholder), 81 | }; 82 | 83 | const valueProps = { 84 | tabIndex: '0', 85 | readOnly: !search, 86 | placeholder, 87 | value: focus && search ? q : snapshot.displayValue, 88 | ref, 89 | ...keyHandlers, 90 | onFocus: (e) => { 91 | setFocus(true); 92 | onFocus(e); 93 | }, 94 | onBlur: (e) => { 95 | setFocus(false); 96 | !option && setSearch(''); 97 | setHighlighted(-1); 98 | onBlur(e); 99 | }, 100 | onMouseDown: (e) => { 101 | if (focus) { 102 | e.preventDefault(); 103 | ref.current.blur(); 104 | } 105 | }, 106 | onChange: search 107 | ? ({ target }) => setSearch(target.value) 108 | : null, 109 | }; 110 | 111 | const optionProps = { 112 | tabIndex: '-1', 113 | onMouseDown(e) { 114 | e.preventDefault(); 115 | onSelect(e.currentTarget.value); 116 | }, 117 | }; 118 | 119 | useEffect(() => { 120 | setOption(getOption( 121 | value === undefined ? defaultValue : value, 122 | options, 123 | )); 124 | }, [value, options]); 125 | 126 | return [snapshot, valueProps, optionProps]; 127 | } 128 | -------------------------------------------------------------------------------- /stories/0-Default.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SelectSearch from '../src'; 3 | import '../style.css'; 4 | import { colors, countries, food } from './data'; 5 | 6 | export default { 7 | title: 'Default', 8 | component: SelectSearch, 9 | }; 10 | 11 | const Template = (args) => ; 12 | 13 | export const Default = Template.bind({}); 14 | 15 | Default.args = { 16 | options: colors, 17 | }; 18 | 19 | export const Search = Template.bind({}); 20 | 21 | Search.args = { 22 | placeholder: 'Search country', 23 | search: true, 24 | options: countries, 25 | }; 26 | 27 | export const Grouped = Template.bind({}); 28 | 29 | Grouped.args = { 30 | options: food, 31 | }; 32 | -------------------------------------------------------------------------------- /stories/1-Multiple.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SelectSearch from '../src'; 3 | import '../style.css'; 4 | import { colors, countries, food } from './data'; 5 | 6 | export default { 7 | title: 'Multiple', 8 | component: SelectSearch, 9 | args: { 10 | multiple: true, 11 | }, 12 | }; 13 | 14 | const Template = (args) => ; 15 | 16 | export const Default = Template.bind({}); 17 | 18 | Default.args = { 19 | options: colors, 20 | }; 21 | 22 | export const Search = Template.bind({}); 23 | 24 | Search.args = { 25 | placeholder: 'Search country', 26 | search: true, 27 | options: countries, 28 | }; 29 | 30 | export const Grouped = Template.bind({}); 31 | 32 | Grouped.args = { 33 | options: food, 34 | }; 35 | -------------------------------------------------------------------------------- /stories/2-Events.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useState } from 'react'; 3 | import SelectSearch from '../src'; 4 | import '../style.css'; 5 | 6 | export default { 7 | title: 'Events', 8 | }; 9 | 10 | export const OnChange = () => { 11 | const [size, setSize] = useState('s'); 12 | const style = { 13 | fontFamily: '"Nunito Sans", sans-serif', 14 | marginTop: '24px', 15 | }; 16 | 17 | if (size === 's') { 18 | style.fontSize = '16px'; 19 | } else if (size === 'm') { 20 | style.fontSize = '32px'; 21 | } 22 | if (size === 'l') { 23 | style.fontSize = '64px'; 24 | } 25 | 26 | return ( 27 | <> 28 | 38 |

    Aa

    39 | 40 | ); 41 | }; 42 | 43 | export const ControlledValue = () => { 44 | const [size, setSize] = useState(null); 45 | const style = { 46 | fontFamily: '"Nunito Sans", sans-serif', 47 | marginTop: '16px', 48 | }; 49 | 50 | const button = { 51 | marginTop: '16px', 52 | display: 'inline-flex', 53 | position: 'relative', 54 | alignItems: 'center', 55 | height: '40px', 56 | padding: '0 16px', 57 | borderRadius: '3px', 58 | border: 'none', 59 | background: 'rgb(49, 173, 122)', 60 | color: '#fff', 61 | fontSize: '16px', 62 | cursor: 'pointer', 63 | outline: 'none', 64 | }; 65 | 66 | const buttonTwo = { 67 | ...button, 68 | background: 'transparent', 69 | border: '2px solid #888', 70 | color: '#888', 71 | marginLeft: '8px', 72 | }; 73 | 74 | return ( 75 | <> 76 | 86 |

    You have selected: {size}

    87 | 90 | 97 | 98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /stories/3-Custom.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SelectSearch from '../src'; 3 | import '../style.css'; 4 | import classes from '../style.module.css'; 5 | import { fontStacks, friends } from './data'; 6 | 7 | export default { 8 | title: 'Custom', 9 | }; 10 | 11 | function renderFontValue(valueProps, snapshot, className) { 12 | const { option } = snapshot; 13 | const style = { 14 | fontFamily: 15 | !snapshot.focus && option && 'stack' in option 16 | ? option.stack 17 | : null, 18 | }; 19 | 20 | return ( 21 | 26 | ); 27 | } 28 | 29 | function renderFontOption(props, { stack, name }, snapshot, className) { 30 | return ( 31 | 34 | ); 35 | } 36 | 37 | function renderFriend(props, option, snapshot, className) { 38 | const imgStyle = { 39 | borderRadius: '50%', 40 | verticalAlign: 'middle', 41 | marginRight: 10, 42 | }; 43 | 44 | return ( 45 | 57 | ); 58 | } 59 | 60 | export const FontExample = () => ( 61 | {str}} 66 | search 67 | defaultValue="Monoton" 68 | /> 69 | ); 70 | 71 | export const AvatarExample = () => ( 72 | 80 | ); 81 | 82 | export const CSSModules = () => ( 83 | 92 | ); 93 | -------------------------------------------------------------------------------- /stories/4-Async.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SelectSearch from '../src'; 3 | import '../style.css'; 4 | 5 | export default { 6 | title: 'Async', 7 | }; 8 | 9 | export const Fetch = () => ( 10 | { 14 | return new Promise((resolve, reject) => { 15 | fetch( 16 | `https://www.thecocktaildb.com/api/json/v1/1/search.php?s=${query}`, 17 | ) 18 | .then((response) => response.json()) 19 | .then(({ drinks }) => { 20 | resolve( 21 | drinks.map(({ idDrink, strDrink }) => ({ 22 | value: idDrink, 23 | name: strDrink, 24 | })), 25 | ); 26 | }) 27 | .catch(reject); 28 | }); 29 | }} 30 | search 31 | placeholder="Your favorite drink" 32 | /> 33 | ); 34 | 35 | export const FetchMultiple = () => ( 36 | { 40 | return new Promise((resolve, reject) => { 41 | fetch( 42 | `https://www.thecocktaildb.com/api/json/v1/1/search.php?s=${query}`, 43 | ) 44 | .then((response) => response.json()) 45 | .then(({ drinks }) => { 46 | resolve( 47 | drinks.map(({ idDrink, strDrink }) => ({ 48 | value: idDrink, 49 | name: strDrink, 50 | })), 51 | ); 52 | }) 53 | .catch(reject); 54 | }); 55 | }} 56 | search 57 | placeholder="Your favorite drink" 58 | /> 59 | ); 60 | -------------------------------------------------------------------------------- /stories/5-Hooks.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { CSSTransition } from 'react-transition-group'; 3 | import classes from './assets/hooks.module.css'; 4 | import useSelect from '../src/useSelect'; 5 | 6 | export default { 7 | title: 'Hooks', 8 | }; 9 | 10 | const classNames = { 11 | appear: classes.appear, 12 | appearActive: classes['appear-active'], 13 | appearDone: classes['appear-done'], 14 | enter: classes.enter, 15 | enterActive: classes['enter-active'], 16 | enterDone: classes['enter-done'], 17 | exit: classes.exit, 18 | exitActive: classes['exit-active'], 19 | exitDone: classes['exit-done'], 20 | }; 21 | 22 | const options = [ 23 | { value: 's', name: 'Small' }, 24 | { value: 'm', name: 'Medium' }, 25 | { value: 'l', name: 'Large' }, 26 | ]; 27 | 28 | const CustomSelect = ({ options, value }) => { 29 | const [snapshot, valueProps, optionProps] = useSelect({ 30 | options, 31 | defaultValue: value, 32 | allowEmpty: false, 33 | }); 34 | 35 | return ( 36 | <> 37 |
    {`Size: ${snapshot.displayValue}`}
    41 | 48 |
    49 |
    50 | {snapshot.options.map((option) => ( 51 | 66 | ))} 67 |
    68 |
    69 |
    70 | 71 | ); 72 | }; 73 | 74 | export const CustomComponents = () => { 75 | return ; 76 | }; 77 | -------------------------------------------------------------------------------- /stories/6-Misc.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SelectSearch from '../src'; 3 | import '../style.css'; 4 | import { useEffect, useState } from "react"; 5 | import { colors } from "./data"; 6 | 7 | export default { 8 | title: 'Misc', 9 | }; 10 | 11 | export const StateOptions = () => { 12 | const [options, setOptions] = useState([]); 13 | 14 | useEffect(() => { 15 | setOptions(colors); 16 | }, []); 17 | 18 | return ( 19 | 24 | ); 25 | }; 26 | 27 | export const NumericValues = () => ( 28 | 36 | ); 37 | 38 | export const Form = () => ( 39 | <> 40 |
    41 | 51 |
    52 | 59 | 60 | ); 61 | -------------------------------------------------------------------------------- /stories/assets/hooks.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | display: inline-flex; 3 | position: relative; 4 | align-items: center; 5 | height: 50px; 6 | padding: 0 16px; 7 | border: none; 8 | border-radius: 3px; 9 | outline: none; 10 | background: rgb(49, 173, 122); 11 | color: #fff; 12 | font-size: 16px; 13 | font-family: 'Nunito Sans', sans-serif; 14 | font-weight: 500; 15 | line-height: 1; 16 | vertical-align: middle; 17 | cursor: pointer; 18 | } 19 | 20 | .select { 21 | position: fixed; 22 | top: 0; 23 | right: 0; 24 | bottom: 0; 25 | left: 0; 26 | background: rgba(0, 0, 0, 0.5); 27 | transition: .3s opacity; 28 | } 29 | 30 | .options { 31 | position: absolute; 32 | top: 50%; 33 | left: 50%; 34 | transform: translate(-50%, -50%); 35 | width: 240px; 36 | padding: 16px 0; 37 | border-radius: 10px; 38 | background: #ffff; 39 | box-shadow: 0 16px 40px rgba(0, 0, 0, 0.05); 40 | overflow: hidden; 41 | transition: .3s transform; 42 | } 43 | 44 | .option { 45 | display: block; 46 | width: 100%; 47 | padding: 16px 24px; 48 | border: none; 49 | background: none; 50 | font-family: 'Nunito Sans', sans-serif; 51 | font-size: 18px; 52 | text-align: left; 53 | cursor: pointer; 54 | transition: .3s background; 55 | outline: none; 56 | } 57 | 58 | .option.is-selected { 59 | background: rgb(103, 121, 225); 60 | color: #fff; 61 | } 62 | 63 | .enter .select { 64 | opacity: 0; 65 | } 66 | 67 | .enter-active .select { 68 | opacity: 1; 69 | } 70 | .exit .select { 71 | opacity: 1; 72 | } 73 | .exit-active .select { 74 | opacity: 0; 75 | } 76 | 77 | .enter .options { 78 | transform: translate(-50%, calc(-50% + 20px)); 79 | } 80 | 81 | .enter-active .options { 82 | transform: translate(-50%, -50%); 83 | } 84 | .exit .options { 85 | transform: translate(-50%, -50%); 86 | } 87 | .exit-active .options { 88 | transform: translate(-50%, calc(-50% + 20px)); 89 | } 90 | -------------------------------------------------------------------------------- /stories/data/index.js: -------------------------------------------------------------------------------- 1 | /** https://gist.github.com/Keeguon/2310008 */ 2 | export const countries = [ 3 | { name: 'Afghanistan', value: 'AF' }, 4 | { name: 'Åland Islands', value: 'AX' }, 5 | { name: 'Albania', value: 'AL' }, 6 | { name: 'Algeria', value: 'DZ' }, 7 | { name: 'American Samoa', value: 'AS' }, 8 | { name: 'AndorrA', value: 'AD' }, 9 | { name: 'Angola', value: 'AO' }, 10 | { name: 'Anguilla', value: 'AI' }, 11 | { name: 'Antarctica', value: 'AQ' }, 12 | { name: 'Antigua and Barbuda', value: 'AG' }, 13 | { name: 'Argentina', value: 'AR' }, 14 | { name: 'Armenia', value: 'AM' }, 15 | { name: 'Aruba', value: 'AW' }, 16 | { name: 'Australia', value: 'AU' }, 17 | { name: 'Austria', value: 'AT' }, 18 | { name: 'Azerbaijan', value: 'AZ' }, 19 | { name: 'Bahamas', value: 'BS' }, 20 | { name: 'Bahrain', value: 'BH' }, 21 | { name: 'Bangladesh', value: 'BD' }, 22 | { name: 'Barbados', value: 'BB' }, 23 | { name: 'Belarus', value: 'BY' }, 24 | { name: 'Belgium', value: 'BE' }, 25 | { name: 'Belize', value: 'BZ' }, 26 | { name: 'Benin', value: 'BJ' }, 27 | { name: 'Bermuda', value: 'BM' }, 28 | { name: 'Bhutan', value: 'BT' }, 29 | { name: 'Bolivia', value: 'BO' }, 30 | { name: 'Bosnia and Herzegovina', value: 'BA' }, 31 | { name: 'Botswana', value: 'BW' }, 32 | { name: 'Bouvet Island', value: 'BV' }, 33 | { name: 'Brazil', value: 'BR' }, 34 | { name: 'British Indian Ocean Territory', value: 'IO' }, 35 | { name: 'Brunei Darussalam', value: 'BN' }, 36 | { name: 'Bulgaria', value: 'BG' }, 37 | { name: 'Burkina Faso', value: 'BF' }, 38 | { name: 'Burundi', value: 'BI' }, 39 | { name: 'Cambodia', value: 'KH' }, 40 | { name: 'Cameroon', value: 'CM' }, 41 | { name: 'Canada', value: 'CA' }, 42 | { name: 'Cape Verde', value: 'CV' }, 43 | { name: 'Cayman Islands', value: 'KY' }, 44 | { name: 'Central African Republic', value: 'CF' }, 45 | { name: 'Chad', value: 'TD' }, 46 | { name: 'Chile', value: 'CL' }, 47 | { name: 'China', value: 'CN' }, 48 | { name: 'Christmas Island', value: 'CX' }, 49 | { name: 'Cocos (Keeling) Islands', value: 'CC' }, 50 | { name: 'Colombia', value: 'CO' }, 51 | { name: 'Comoros', value: 'KM' }, 52 | { name: 'Congo', value: 'CG' }, 53 | { name: 'Congo, The Democratic Republic of the', value: 'CD' }, 54 | { name: 'Cook Islands', value: 'CK' }, 55 | { name: 'Costa Rica', value: 'CR' }, 56 | { name: "Cote D'Ivoire", value: 'CI' }, 57 | { name: 'Croatia', value: 'HR' }, 58 | { name: 'Cuba', value: 'CU' }, 59 | { name: 'Cyprus', value: 'CY' }, 60 | { name: 'Czech Republic', value: 'CZ' }, 61 | { name: 'Denmark', value: 'DK' }, 62 | { name: 'Djibouti', value: 'DJ' }, 63 | { name: 'Dominica', value: 'DM' }, 64 | { name: 'Dominican Republic', value: 'DO' }, 65 | { name: 'Ecuador', value: 'EC' }, 66 | { name: 'Egypt', value: 'EG' }, 67 | { name: 'El Salvador', value: 'SV' }, 68 | { name: 'Equatorial Guinea', value: 'GQ' }, 69 | { name: 'Eritrea', value: 'ER' }, 70 | { name: 'Estonia', value: 'EE' }, 71 | { name: 'Ethiopia', value: 'ET' }, 72 | { name: 'Falkland Islands (Malvinas)', value: 'FK' }, 73 | { name: 'Faroe Islands', value: 'FO' }, 74 | { name: 'Fiji', value: 'FJ' }, 75 | { name: 'Finland', value: 'FI' }, 76 | { name: 'France', value: 'FR' }, 77 | { name: 'French Guiana', value: 'GF' }, 78 | { name: 'French Polynesia', value: 'PF' }, 79 | { name: 'French Southern Territories', value: 'TF' }, 80 | { name: 'Gabon', value: 'GA' }, 81 | { name: 'Gambia', value: 'GM' }, 82 | { name: 'Georgia', value: 'GE' }, 83 | { name: 'Germany', value: 'DE' }, 84 | { name: 'Ghana', value: 'GH' }, 85 | { name: 'Gibraltar', value: 'GI' }, 86 | { name: 'Greece', value: 'GR' }, 87 | { name: 'Greenland', value: 'GL' }, 88 | { name: 'Grenada', value: 'GD' }, 89 | { name: 'Guadeloupe', value: 'GP' }, 90 | { name: 'Guam', value: 'GU' }, 91 | { name: 'Guatemala', value: 'GT' }, 92 | { name: 'Guernsey', value: 'GG' }, 93 | { name: 'Guinea', value: 'GN' }, 94 | { name: 'Guinea-Bissau', value: 'GW' }, 95 | { name: 'Guyana', value: 'GY' }, 96 | { name: 'Haiti', value: 'HT' }, 97 | { name: 'Heard Island and Mcdonald Islands', value: 'HM' }, 98 | { name: 'Holy See (Vatican City State)', value: 'VA' }, 99 | { name: 'Honduras', value: 'HN' }, 100 | { name: 'Hong Kong', value: 'HK' }, 101 | { name: 'Hungary', value: 'HU' }, 102 | { name: 'Iceland', value: 'IS' }, 103 | { name: 'India', value: 'IN' }, 104 | { name: 'Indonesia', value: 'ID' }, 105 | { name: 'Iran, Islamic Republic Of', value: 'IR' }, 106 | { name: 'Iraq', value: 'IQ' }, 107 | { name: 'Ireland', value: 'IE' }, 108 | { name: 'Isle of Man', value: 'IM' }, 109 | { name: 'Israel', value: 'IL' }, 110 | { name: 'Italy', value: 'IT' }, 111 | { name: 'Jamaica', value: 'JM' }, 112 | { name: 'Japan', value: 'JP' }, 113 | { name: 'Jersey', value: 'JE' }, 114 | { name: 'Jordan', value: 'JO' }, 115 | { name: 'Kazakhstan', value: 'KZ' }, 116 | { name: 'Kenya', value: 'KE' }, 117 | { name: 'Kiribati', value: 'KI' }, 118 | { name: "Korea, Democratic People'S Republic of", value: 'KP' }, 119 | { name: 'Korea, Republic of', value: 'KR' }, 120 | { name: 'Kuwait', value: 'KW' }, 121 | { name: 'Kyrgyzstan', value: 'KG' }, 122 | { name: "Lao People'S Democratic Republic", value: 'LA' }, 123 | { name: 'Latvia', value: 'LV' }, 124 | { name: 'Lebanon', value: 'LB' }, 125 | { name: 'Lesotho', value: 'LS' }, 126 | { name: 'Liberia', value: 'LR' }, 127 | { name: 'Libyan Arab Jamahiriya', value: 'LY' }, 128 | { name: 'Liechtenstein', value: 'LI' }, 129 | { name: 'Lithuania', value: 'LT' }, 130 | { name: 'Luxembourg', value: 'LU' }, 131 | { name: 'Macao', value: 'MO' }, 132 | { name: 'Macedonia, The Former Yugoslav Republic of', value: 'MK' }, 133 | { name: 'Madagascar', value: 'MG' }, 134 | { name: 'Malawi', value: 'MW' }, 135 | { name: 'Malaysia', value: 'MY' }, 136 | { name: 'Maldives', value: 'MV' }, 137 | { name: 'Mali', value: 'ML' }, 138 | { name: 'Malta', value: 'MT' }, 139 | { name: 'Marshall Islands', value: 'MH' }, 140 | { name: 'Martinique', value: 'MQ' }, 141 | { name: 'Mauritania', value: 'MR' }, 142 | { name: 'Mauritius', value: 'MU' }, 143 | { name: 'Mayotte', value: 'YT' }, 144 | { name: 'Mexico', value: 'MX' }, 145 | { name: 'Micronesia, Federated States of', value: 'FM' }, 146 | { name: 'Moldova, Republic of', value: 'MD' }, 147 | { name: 'Monaco', value: 'MC' }, 148 | { name: 'Mongolia', value: 'MN' }, 149 | { name: 'Montserrat', value: 'MS' }, 150 | { name: 'Morocco', value: 'MA' }, 151 | { name: 'Mozambique', value: 'MZ' }, 152 | { name: 'Myanmar', value: 'MM' }, 153 | { name: 'Namibia', value: 'NA' }, 154 | { name: 'Nauru', value: 'NR' }, 155 | { name: 'Nepal', value: 'NP' }, 156 | { name: 'Netherlands', value: 'NL' }, 157 | { name: 'Netherlands Antilles', value: 'AN' }, 158 | { name: 'New Caledonia', value: 'NC' }, 159 | { name: 'New Zealand', value: 'NZ' }, 160 | { name: 'Nicaragua', value: 'NI' }, 161 | { name: 'Niger', value: 'NE' }, 162 | { name: 'Nigeria', value: 'NG' }, 163 | { name: 'Niue', value: 'NU' }, 164 | { name: 'Norfolk Island', value: 'NF' }, 165 | { name: 'Northern Mariana Islands', value: 'MP' }, 166 | { name: 'Norway', value: 'NO' }, 167 | { name: 'Oman', value: 'OM' }, 168 | { name: 'Pakistan', value: 'PK' }, 169 | { name: 'Palau', value: 'PW' }, 170 | { name: 'Palestinian Territory, Occupied', value: 'PS' }, 171 | { name: 'Panama', value: 'PA' }, 172 | { name: 'Papua New Guinea', value: 'PG' }, 173 | { name: 'Paraguay', value: 'PY' }, 174 | { name: 'Peru', value: 'PE' }, 175 | { name: 'Philippines', value: 'PH' }, 176 | { name: 'Pitcairn', value: 'PN' }, 177 | { name: 'Poland', value: 'PL' }, 178 | { name: 'Portugal', value: 'PT' }, 179 | { name: 'Puerto Rico', value: 'PR' }, 180 | { name: 'Qatar', value: 'QA' }, 181 | { name: 'Reunion', value: 'RE' }, 182 | { name: 'Romania', value: 'RO' }, 183 | { name: 'Russian Federation', value: 'RU' }, 184 | { name: 'RWANDA', value: 'RW' }, 185 | { name: 'Saint Helena', value: 'SH' }, 186 | { name: 'Saint Kitts and Nevis', value: 'KN' }, 187 | { name: 'Saint Lucia', value: 'LC' }, 188 | { name: 'Saint Pierre and Miquelon', value: 'PM' }, 189 | { name: 'Saint Vincent and the Grenadines', value: 'VC' }, 190 | { name: 'Samoa', value: 'WS' }, 191 | { name: 'San Marino', value: 'SM' }, 192 | { name: 'Sao Tome and Principe', value: 'ST' }, 193 | { name: 'Saudi Arabia', value: 'SA' }, 194 | { name: 'Senegal', value: 'SN' }, 195 | { name: 'Serbia and Montenegro', value: 'CS' }, 196 | { name: 'Seychelles', value: 'SC' }, 197 | { name: 'Sierra Leone', value: 'SL' }, 198 | { name: 'Singapore', value: 'SG' }, 199 | { name: 'Slovakia', value: 'SK' }, 200 | { name: 'Slovenia', value: 'SI' }, 201 | { name: 'Solomon Islands', value: 'SB' }, 202 | { name: 'Somalia', value: 'SO' }, 203 | { name: 'South Africa', value: 'ZA' }, 204 | { name: 'South Georgia and the South Sandwich Islands', value: 'GS' }, 205 | { name: 'Spain', value: 'ES' }, 206 | { name: 'Sri Lanka', value: 'LK' }, 207 | { name: 'Sudan', value: 'SD' }, 208 | { name: 'Suriname', value: 'SR' }, 209 | { name: 'Svalbard and Jan Mayen', value: 'SJ' }, 210 | { name: 'Swaziland', value: 'SZ' }, 211 | { name: 'Sweden', value: 'SE' }, 212 | { name: 'Switzerland', value: 'CH' }, 213 | { name: 'Syrian Arab Republic', value: 'SY' }, 214 | { name: 'Taiwan, Province of China', value: 'TW' }, 215 | { name: 'Tajikistan', value: 'TJ' }, 216 | { name: 'Tanzania, United Republic of', value: 'TZ' }, 217 | { name: 'Thailand', value: 'TH' }, 218 | { name: 'Timor-Leste', value: 'TL' }, 219 | { name: 'Togo', value: 'TG' }, 220 | { name: 'Tokelau', value: 'TK' }, 221 | { name: 'Tonga', value: 'TO' }, 222 | { name: 'Trinidad and Tobago', value: 'TT' }, 223 | { name: 'Tunisia', value: 'TN' }, 224 | { name: 'Turkey', value: 'TR' }, 225 | { name: 'Turkmenistan', value: 'TM' }, 226 | { name: 'Turks and Caicos Islands', value: 'TC' }, 227 | { name: 'Tuvalu', value: 'TV' }, 228 | { name: 'Uganda', value: 'UG' }, 229 | { name: 'Ukraine', value: 'UA' }, 230 | { name: 'United Arab Emirates', value: 'AE' }, 231 | { name: 'United Kingdom', value: 'GB' }, 232 | { name: 'United States', value: 'US' }, 233 | { name: 'United States Minor Outlying Islands', value: 'UM' }, 234 | { name: 'Uruguay', value: 'UY' }, 235 | { name: 'Uzbekistan', value: 'UZ' }, 236 | { name: 'Vanuatu', value: 'VU' }, 237 | { name: 'Venezuela', value: 'VE' }, 238 | { name: 'Viet Nam', value: 'VN' }, 239 | { name: 'Virgin Islands, British', value: 'VG' }, 240 | { name: 'Virgin Islands, U.S.', value: 'VI' }, 241 | { name: 'Wallis and Futuna', value: 'WF' }, 242 | { name: 'Western Sahara', value: 'EH' }, 243 | { name: 'Yemen', value: 'YE' }, 244 | { name: 'Zambia', value: 'ZM' }, 245 | { name: 'Zimbabwe', value: 'ZW' }, 246 | ]; 247 | 248 | export const fontStacks = [ 249 | { 250 | type: 'group', 251 | name: 'Sans serif', 252 | items: [ 253 | { name: 'Roboto', value: 'Roboto', stack: 'Roboto, sans-serif' }, 254 | { 255 | name: 'Helvetica', 256 | value: 'Helvetica', 257 | stack: 'Helvetica, sans-serif', 258 | }, 259 | ], 260 | }, 261 | { 262 | type: 'group', 263 | name: 'Serif', 264 | items: [ 265 | { 266 | name: 'Playfair Display', 267 | value: 'Playfair Display', 268 | stack: '"Playfair Display", serif', 269 | }, 270 | ], 271 | }, 272 | { 273 | type: 'group', 274 | name: 'Cursive', 275 | items: [ 276 | { name: 'Monoton', value: 'Monoton', stack: 'Monoton, cursive' }, 277 | { 278 | name: 'Gloria Hallelujah', 279 | value: '"Gloria Hallelujah", cursive', 280 | stack: '"Gloria Hallelujah", cursive', 281 | }, 282 | ], 283 | }, 284 | { 285 | type: 'group', 286 | name: 'Monospace', 287 | items: [{ name: 'VT323', value: 'VT323', stack: 'VT323, monospace' }], 288 | }, 289 | ]; 290 | 291 | // https://randomuser.me/ 292 | export const friends = [ 293 | { 294 | name: 'Annie Cruz', 295 | value: 'annie.cruz', 296 | photo: 'https://randomuser.me/api/portraits/women/60.jpg', 297 | }, 298 | { 299 | name: 'Eli Shelton', 300 | disabled: true, 301 | value: 'eli.shelton', 302 | photo: 'https://randomuser.me/api/portraits/men/7.jpg', 303 | }, 304 | { 305 | name: 'Loretta Rogers', 306 | value: 'loretta.rogers', 307 | photo: 'https://randomuser.me/api/portraits/women/51.jpg', 308 | }, 309 | { 310 | name: 'Lloyd Fisher', 311 | value: 'lloyd.fisher', 312 | photo: 'https://randomuser.me/api/portraits/men/34.jpg', 313 | }, 314 | { 315 | name: 'Tiffany Gonzales', 316 | value: 'tiffany.gonzales', 317 | photo: 'https://randomuser.me/api/portraits/women/71.jpg', 318 | }, 319 | { 320 | name: 'Charles Hardy', 321 | value: 'charles.hardy', 322 | photo: 'https://randomuser.me/api/portraits/men/12.jpg', 323 | }, 324 | { 325 | name: 'Rudolf Wilson', 326 | value: 'rudolf.wilson', 327 | photo: 'https://randomuser.me/api/portraits/men/40.jpg', 328 | }, 329 | { 330 | name: 'Emerald Hensley', 331 | value: 'emerald.hensley', 332 | photo: 'https://randomuser.me/api/portraits/women/1.jpg', 333 | }, 334 | { 335 | name: 'Lorena McCoy', 336 | value: 'lorena.mccoy', 337 | photo: 'https://randomuser.me/api/portraits/women/70.jpg', 338 | }, 339 | { 340 | name: 'Alicia Lamb', 341 | value: 'alicia.lamb', 342 | photo: 'https://randomuser.me/api/portraits/women/22.jpg', 343 | }, 344 | { 345 | name: 'Maria Waters', 346 | value: 'maria.waters', 347 | photo: 'https://randomuser.me/api/portraits/women/82.jpg', 348 | }, 349 | ]; 350 | 351 | export const colors = [ 352 | { name: 'Orange', value: 'orange' }, 353 | { name: 'Red', value: 'red' }, 354 | { name: 'Blue', value: 'blue' }, 355 | { name: 'Purple', value: 'purple' }, 356 | { name: 'Yellow', value: 'yellow' }, 357 | ]; 358 | 359 | export const food = [ 360 | { 361 | name: 'Food', 362 | type: 'group', 363 | items: [ 364 | { 365 | value: 'hamburger', 366 | name: 'Hamburger', 367 | }, 368 | { 369 | value: 'pizza', 370 | name: 'Pizza', 371 | }, 372 | ], 373 | }, 374 | { 375 | name: 'Drinks', 376 | type: 'group', 377 | items: [ 378 | { 379 | value: 'soft', 380 | name: 'Soft drink', 381 | }, 382 | { 383 | value: 'beer', 384 | name: 'Beer', 385 | }, 386 | ], 387 | }, 388 | ]; 389 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | @import url('https://rsms.me/inter/inter.css'); 2 | 3 | /** 4 | * Main wrapper 5 | */ 6 | .select-search-container { 7 | --select-search-background: #fff; 8 | --select-search-border: #dce0e8; 9 | --select-search-selected: #1e66f5; 10 | --select-search-text: #000; 11 | --select-search-subtle-text: #6c6f85; 12 | --select-search-inverted-text: var(--select-search-background); 13 | --select-search-highlight: #eff1f5; 14 | --select-search-font: 'Inter', sans-serif; 15 | 16 | width: 300px; 17 | position: relative; 18 | font-family: var(--select-search-font); 19 | color: var(--select-search-text); 20 | box-sizing: border-box; 21 | } 22 | 23 | @supports (font-variation-settings: normal) { 24 | .select-search-container { 25 | --select-search-font: 'Inter var', sans-serif; 26 | } 27 | } 28 | 29 | @media (prefers-color-scheme: dark) { 30 | .select-search-container { 31 | --select-search-background: #000; 32 | --select-search-border: #313244; 33 | --select-search-selected: #89b4fa; 34 | --select-search-text: #fff; 35 | --select-search-subtle-text: #a6adc8; 36 | --select-search-highlight: #1e1e2e; 37 | } 38 | } 39 | 40 | body.is-dark-mode .select-search-container { 41 | --select-search-background: #000; 42 | --select-search-border: #313244; 43 | --select-search-selected: #89b4fa; 44 | --select-search-text: #fff; 45 | --select-search-subtle-text: #a6adc8; 46 | --select-search-highlight: #1e1e2e; 47 | } 48 | 49 | body.is-light-mode .select-search-container { 50 | --select-search-background: #fff; 51 | --select-search-border: #dce0e8; 52 | --select-search-selected: #1e66f5; 53 | --select-search-text: #000; 54 | --select-search-subtle-text: #6c6f85; 55 | --select-search-highlight: #eff1f5; 56 | } 57 | 58 | .select-search-container *, 59 | .select-search-container *::after, 60 | .select-search-container *::before { 61 | box-sizing: inherit; 62 | } 63 | 64 | .select-search-input { 65 | position: relative; 66 | z-index: 1; 67 | display: block; 68 | height: 48px; 69 | width: 100%; 70 | padding: 0 40px 0 16px; 71 | background: var(--select-search-background); 72 | border: 2px solid var(--select-search-border); 73 | color: var(--select-search-text); 74 | border-radius: 3px; 75 | outline: none; 76 | font-family: var(--select-search-font); 77 | font-size: 16px; 78 | text-align: left; 79 | text-overflow: ellipsis; 80 | line-height: 48px; 81 | letter-spacing: 0.01rem; 82 | -webkit-appearance: none; 83 | -webkit-font-smoothing: antialiased; 84 | } 85 | 86 | .select-search-is-multiple .select-search-input { 87 | margin-bottom: -2px; 88 | } 89 | 90 | .select-search-is-multiple .select-search-input { 91 | border-radius: 3px 3px 0 0; 92 | } 93 | 94 | .select-search-input::-webkit-search-decoration, 95 | .select-search-input::-webkit-search-cancel-button, 96 | .select-search-input::-webkit-search-results-button, 97 | .select-search-input::-webkit-search-results-decoration { 98 | -webkit-appearance:none; 99 | } 100 | 101 | .select-search-input[readonly] { 102 | cursor: pointer; 103 | } 104 | 105 | .select-search-is-disabled .select-search-input { 106 | cursor: not-allowed; 107 | } 108 | 109 | .select-search-container:not(.select-search-is-disabled).select-search-has-focus .select-search-input, 110 | .select-search-container:not(.select-search-is-disabled) .select-search-input:hover { 111 | border-color: var(--select-search-selected); 112 | } 113 | 114 | .select-search-select { 115 | background: var(--select-search-background); 116 | box-shadow: 0 .0625rem .125rem rgba(0, 0, 0, 0.15); 117 | border: 2px solid var(--select-search-border); 118 | overflow: auto; 119 | max-height: 360px; 120 | } 121 | 122 | .select-search-container:not(.select-search-is-multiple) .select-search-select { 123 | position: absolute; 124 | z-index: 2; 125 | top: 58px; 126 | right: 0; 127 | left: 0; 128 | border-radius: 3px; 129 | display: none; 130 | } 131 | 132 | .select-search-container:not(.select-search-is-multiple).select-search-has-focus .select-search-select { 133 | display: block; 134 | } 135 | 136 | .select-search-has-focus .select-search-select { 137 | border-color: var(--select-search-selected); 138 | } 139 | 140 | .select-search-options { 141 | list-style: none; 142 | } 143 | 144 | .select-search-option, 145 | .select-search-not-found { 146 | display: block; 147 | height: 42px; 148 | width: 100%; 149 | padding: 0 16px; 150 | background: var(--select-search-background); 151 | border: none; 152 | outline: none; 153 | font-family: var(--select-search-font); 154 | color: var(--select-search-text); 155 | font-size: 16px; 156 | text-align: left; 157 | letter-spacing: 0.01rem; 158 | cursor: pointer; 159 | -webkit-font-smoothing: antialiased; 160 | } 161 | 162 | .select-search-option:disabled { 163 | opacity: 0.5; 164 | cursor: not-allowed; 165 | background: transparent !important; 166 | } 167 | 168 | .select-search-is-highlighted, 169 | .select-search-option:not(.select-search-is-selected):hover { 170 | background: var(--select-search-highlight); 171 | } 172 | 173 | .select-search-is-selected { 174 | font-weight: bold; 175 | color: var(--select-search-selected); 176 | } 177 | 178 | .select-search-group-header { 179 | font-size: 12px; 180 | text-transform: uppercase; 181 | background: var(--select-search-border); 182 | color: var(--select-search-subtle-text); 183 | letter-spacing: 0.1rem; 184 | padding: 10px 16px; 185 | } 186 | 187 | .select-search-row:not(:first-child) .select-search-group-header { 188 | margin-top: 10px; 189 | } 190 | 191 | .select-search-row:not(:last-child) .select-search-group-header { 192 | margin-bottom: 10px; 193 | } 194 | -------------------------------------------------------------------------------- /style.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | border-radius: 10px; 3 | background: #ffff; 4 | box-shadow: 0 16px 40px rgba(0, 0, 0, 0.05); 5 | border: 2px solid transparent; 6 | overflow: hidden; 7 | transition: .3s border-color; 8 | } 9 | 10 | .container:hover { 11 | border-color: #6779E1; 12 | } 13 | 14 | .options { 15 | list-style: none; 16 | } 17 | 18 | .row { 19 | display: block; 20 | } 21 | 22 | .option { 23 | display: block; 24 | width: 100%; 25 | padding: 16px; 26 | border: none; 27 | background: none; 28 | font-family: 'Nunito Sans', sans-serif; 29 | font-size: 18px; 30 | text-align: left; 31 | cursor: pointer; 32 | transition: .3s background; 33 | outline: none; 34 | } 35 | 36 | .option.is-selected { 37 | background: rgb(103, 121, 225); 38 | color: #fff; 39 | } 40 | --------------------------------------------------------------------------------