├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── .gitignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── babel.config.json ├── jest.config.js ├── package.json ├── src ├── components │ ├── ClearIcon.tsx │ ├── ReactSearchAutocomplete.tsx │ ├── Results.tsx │ ├── SearchIcon.tsx │ └── SearchInput.tsx ├── config │ └── config.ts ├── index.ts └── utils │ └── utils.ts ├── test ├── ReactSearchAutocomplete.test.tsx ├── Results.test.tsx └── SearchInput.test.tsx ├── tsconfig.json └── yarn.lock /.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 | **Codesandbox example** 14 | Create a [codesandbox](https://codesandbox.io/) example with the problem you would like to have solved. 15 | 16 | **To Reproduce** 17 | Steps to reproduce the behavior: 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /build 4 | .eslintcache 5 | .DS_Store 6 | /coverage 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "arrowParens": "always", 11 | "quoteProps": "consistent" 12 | } 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 17 5 | 6 | install: 7 | - travis_wait yarn install 8 | 9 | script: 10 | - yarn run test 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Reale Roberto Josef Antonio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![travis](https://travis-ci.com/sickdyd/react-search-autocomplete.svg?branch=master) 2 | 3 | ## `` 4 | 5 | A `` is a fully customizable search box where the user can type text and filter the results. It relies on [Fuse.js v6.5.3](https://fusejs.io/) for the fuzzy search. Check out their website to see the options (you can pass them to this component). 6 | 7 | [Click here to see a demo](https://sickdyd.github.io/react-search-autocomplete/). 8 | 9 | [Demo source](https://github.com/sickdyd/react-search-autocomplete/tree/demo). 10 | 11 | ### Installing 12 | 13 | ```bash 14 | $ npm install react-search-autocomplete 15 | or 16 | $ yarn add react-search-autocomplete 17 | ``` 18 | 19 | ### Exports 20 | 21 | The default export is ``. 22 | To use it: 23 | 24 | ```js 25 | import { ReactSearchAutocomplete } from 'react-search-autocomplete' 26 | ``` 27 | 28 | ### React Search Autocomplete Usage 29 | 30 | ```js 31 | import React from 'react' 32 | import './App.css' 33 | import { ReactSearchAutocomplete } from 'react-search-autocomplete' 34 | 35 | function App() { 36 | // note: the id field is mandatory 37 | const items = [ 38 | { 39 | id: 0, 40 | name: 'Cobol' 41 | }, 42 | { 43 | id: 1, 44 | name: 'JavaScript' 45 | }, 46 | { 47 | id: 2, 48 | name: 'Basic' 49 | }, 50 | { 51 | id: 3, 52 | name: 'PHP' 53 | }, 54 | { 55 | id: 4, 56 | name: 'Java' 57 | } 58 | ] 59 | 60 | const handleOnSearch = (string, results) => { 61 | // onSearch will have as the first callback parameter 62 | // the string searched and for the second the results. 63 | console.log(string, results) 64 | } 65 | 66 | const handleOnHover = (result) => { 67 | // the item hovered 68 | console.log(result) 69 | } 70 | 71 | const handleOnSelect = (item) => { 72 | // the item selected 73 | console.log(item) 74 | } 75 | 76 | const handleOnFocus = () => { 77 | console.log('Focused') 78 | } 79 | 80 | const formatResult = (item) => { 81 | return ( 82 | <> 83 | id: {item.id} 84 | name: {item.name} 85 | 86 | ) 87 | } 88 | 89 | return ( 90 |
91 |
92 |
93 | 102 |
103 |
104 |
105 | ) 106 | } 107 | 108 | export default App 109 | ``` 110 | 111 | #### With TypeScript 112 | 113 | ```ts 114 | type Item = { 115 | id: number; 116 | name: string; 117 | } 118 | 119 | ... /> 120 | ``` 121 | 122 | #### `` Props: 123 | 124 | ```ts 125 | { 126 | items, 127 | // The list of items that can be filtered, it can be an array of 128 | // any type of object. Note: the id field is mandatory. 129 | // By default the search will be done on the 130 | // property "name", to change this behaviour, change the `fuseOptions` 131 | // prop. Remember that the component uses the key "name" in your 132 | // items list to display the result. If your list of items does not 133 | // have a "name" key, use `resultStringKeyName` to tell what key 134 | // (string) to use to display in the results. 135 | fuseOptions, 136 | // To know more about fuse params, visit https://fusejs.io/ 137 | // 138 | // By default set to: 139 | // { 140 | // shouldSort: true, 141 | // threshold: 0.6, 142 | // location: 0, 143 | // distance: 100, 144 | // maxPatternLength: 32, 145 | // minMatchCharLength: 1, 146 | // keys: [ 147 | // "name", 148 | // ] 149 | // } 150 | // 151 | // `keys` represent the keys in `items` where the search will be 152 | // performed. 153 | // 154 | // Imagine for example that I want to search in `items` by `title` 155 | // and `description` in the following items, and display the `title`; 156 | // this is how to do it: 157 | // 158 | // const items = [ 159 | // { 160 | // id: 0, 161 | // title: 'Titanic', 162 | // description: 'A movie about love' 163 | // }, 164 | // { 165 | // id: 1, 166 | // title: 'Dead Poets Society', 167 | // description: 'A movie about poetry and the meaning of life' 168 | // } 169 | // ] 170 | // 171 | // I can pass the fuseOptions prop as follows: 172 | // 173 | // 179 | // 180 | resultStringKeyName, 181 | // The key in `items` that contains the string to display in the 182 | // results 183 | inputSearchString, 184 | // By changing this prop, you can manually set the search string. 185 | inputDebounce, 186 | // Default value: 200. When the user is typing, before 187 | // calling onSearch wait this amount of ms. 188 | onSearch, 189 | // The callback function called when the user is searching 190 | onHover, 191 | // THe callback function called when the user hovers a result 192 | onSelect, 193 | // The callback function called when the user selects an item 194 | // from the filtered list. 195 | onFocus, 196 | // The callback function called when the user focuses the input. 197 | onClear, 198 | // The callback called when the user clears the input box by clicking 199 | // on the clear icon. 200 | showIcon, 201 | // Default value: true. If set to false, the icon is hidden. 202 | showClear, 203 | // Default value: true. If set to false, the clear icon is hidden. 204 | maxResults, 205 | // Default value: 10. The max number of results to show at once. 206 | placeholder, 207 | // Default value: "". The placeholder of the search box. 208 | autoFocus, 209 | // Default value: false. If set to true, automatically 210 | // set focus on the input. 211 | styling, 212 | // The styling prop allows you to customize the 213 | // look of the searchbox 214 | // Default values: 215 | // { 216 | // height: "44px", 217 | // border: "1px solid #dfe1e5", 218 | // borderRadius: "24px", 219 | // backgroundColor: "white", 220 | // boxShadow: "rgba(32, 33, 36, 0.28) 0px 1px 6px 0px", 221 | // hoverBackgroundColor: "#eee", 222 | // color: "#212121", 223 | // fontSize: "16px", 224 | // fontFamily: "Arial", 225 | // iconColor: "grey", 226 | // lineColor: "rgb(232, 234, 237)", 227 | // placeholderColor: "grey", 228 | // clearIconMargin: '3px 14px 0 0', 229 | // searchIconMargin: '0 0 0 16px' 230 | // }; 231 | // 232 | // For example, if you want to change the background 233 | // color you can pass it in the props: 234 | // styling={ 235 | // { 236 | // backgroundColor: "black" 237 | // } 238 | // } 239 | formatResult, 240 | // The callback function used to format how the results are displayed. 241 | showNoResults, 242 | // Optional, default value: true, it will display "No results" or showNoResultsText 243 | // if no results are found 244 | showNoResultsText, 245 | // Optional, default value: "No results", the text to display when no results 246 | // are found, 247 | showItemsOnFocus, 248 | // Optional, default value: false, it will automatically show N (maxResults) number of items 249 | // when focusing the input element 250 | maxLength, 251 | // Optional: limits the number of characters that can be typed in the input 252 | className, 253 | // Optional: allows using a custom class to customize CSS on all contained elements 254 | } 255 | ``` 256 | 257 | ### License 258 | 259 | MIT 260 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | "@babel/preset-env", 5 | ["@babel/preset-react", { "runtime": "automatic" }] 6 | ], 7 | "plugins": [] 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { testEnvironment: 'jest-environment-jsdom' } 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-search-autocomplete", 3 | "author": "Reale Roberto Josef Antonio", 4 | "license": "MIT", 5 | "version": "8.5.2", 6 | "description": "A search box for React", 7 | "main": "dist/index.js", 8 | "scripts": { 9 | "test": "jest --config=jest.config.js --verbose --runInBand ", 10 | "coverage": "jest --config=jest.config.js --coverage", 11 | "deploy": "yarn build && npm publish", 12 | "build": "rm -rf ./dist && tsc", 13 | "build-watch": "rm -rf ./dist && tsc --watch", 14 | "typecheck": "tsc --noEmit" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/sickdyd/react-search-autocomplete.git" 19 | }, 20 | "keywords": [ 21 | "search", 22 | "searchbox", 23 | "autocomplete", 24 | "suggestion", 25 | "suggestions", 26 | "google", 27 | "autosuggest", 28 | "autocomplete", 29 | "auto suggest", 30 | "auto complete", 31 | "react autosuggest", 32 | "react autocomplete", 33 | "react auto suggest", 34 | "react auto complete" 35 | ], 36 | "browserslist": { 37 | "production": [ 38 | "> 0.2%", 39 | "not dead", 40 | "not op_mini all", 41 | "not ie <= 11" 42 | ], 43 | "development": [ 44 | "last 1 chrome version", 45 | "last 1 firefox version", 46 | "last 1 safari version" 47 | ] 48 | }, 49 | "bugs": { 50 | "url": "https://github.com/sickdyd/react-search-autocomplete/issues" 51 | }, 52 | "homepage": "https://github.com/sickdyd/react-search-autocomplete#readme", 53 | "dependencies": { 54 | "fuse.js": "^6.5.3", 55 | "styled-components": "^5.3.3" 56 | }, 57 | "peerDependencies": { 58 | "react": "^17.0.2 || ^16.0.2 || ^18.0.0", 59 | "react-dom": "^17.0.2 || ^16.0.2 || ^18.0.0" 60 | }, 61 | "devDependencies": { 62 | "@babel/cli": "^7.12.10", 63 | "@babel/core": "^7.12.10", 64 | "@babel/polyfill": "^7.12.1", 65 | "@babel/preset-env": "^7.12.11", 66 | "@babel/preset-react": "^7.12.10", 67 | "@babel/preset-typescript": "^7.16.7", 68 | "@testing-library/jest-dom": "^5.12.0", 69 | "@testing-library/react": "^11.2.6", 70 | "@types/styled-components": "^5.1.26", 71 | "babel-minify": "^0.5.1", 72 | "jest": "^27.5.1", 73 | "jest-localstorage-mock": "^2.4.6", 74 | "react": "^17.0.2", 75 | "react-dom": "^17.0.2", 76 | "react-test-renderer": "^17.0.2", 77 | "typescript": "^4.9.3" 78 | }, 79 | "files": [ 80 | "dist/*", 81 | "index.d.ts" 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /src/components/ClearIcon.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const ClearIcon = ({ 4 | showClear, 5 | setSearchString, 6 | searchString, 7 | setFocus, 8 | onClear 9 | }: { 10 | showClear: boolean 11 | setSearchString: Function 12 | searchString: string 13 | setFocus: Function 14 | onClear: Function 15 | }) => { 16 | const handleClearSearchString = () => { 17 | setSearchString({ target: { value: '' } }) 18 | setFocus() 19 | onClear() 20 | } 21 | 22 | if (!showClear) { 23 | return null 24 | } 25 | 26 | if (!searchString || searchString?.length <= 0) { 27 | return null 28 | } 29 | 30 | return ( 31 | 32 | 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | const StyledClearIcon = styled.div` 46 | margin: ${(props: any) => props.theme.clearIconMargin}; 47 | 48 | &:hover { 49 | cursor: pointer; 50 | } 51 | 52 | > svg { 53 | fill: ${(props: any) => props.theme.iconColor}; 54 | } 55 | ` 56 | -------------------------------------------------------------------------------- /src/components/ReactSearchAutocomplete.tsx: -------------------------------------------------------------------------------- 1 | import { default as Fuse } from 'fuse.js' 2 | import React, { 3 | ChangeEvent, 4 | FocusEvent, 5 | FocusEventHandler, 6 | KeyboardEvent, 7 | useEffect, 8 | useState 9 | } from 'react' 10 | import styled, { ThemeProvider } from 'styled-components' 11 | import { DefaultTheme, defaultFuseOptions, defaultTheme } from '../config/config' 12 | import { debounce } from '../utils/utils' 13 | import Results, { Item } from './Results' 14 | import SearchInput from './SearchInput' 15 | 16 | export const DEFAULT_INPUT_DEBOUNCE = 200 17 | export const MAX_RESULTS = 10 18 | 19 | export interface ReactSearchAutocompleteProps { 20 | items: T[] 21 | fuseOptions?: Fuse.IFuseOptions 22 | inputDebounce?: number 23 | onSearch?: (keyword: string, results: T[]) => void 24 | onHover?: (result: T) => void 25 | onSelect?: (result: T) => void 26 | onFocus?: FocusEventHandler 27 | onClear?: Function 28 | showIcon?: boolean 29 | showClear?: boolean 30 | maxResults?: number 31 | placeholder?: string 32 | autoFocus?: boolean 33 | styling?: DefaultTheme 34 | resultStringKeyName?: string 35 | inputSearchString?: string 36 | formatResult?: Function 37 | showNoResults?: boolean 38 | showNoResultsText?: string 39 | showItemsOnFocus?: boolean 40 | maxLength?: number 41 | className?: string 42 | } 43 | 44 | export default function ReactSearchAutocomplete({ 45 | items = [], 46 | fuseOptions = defaultFuseOptions, 47 | inputDebounce = DEFAULT_INPUT_DEBOUNCE, 48 | onSearch = () => {}, 49 | onHover = () => {}, 50 | onSelect = () => {}, 51 | onFocus = () => {}, 52 | onClear = () => {}, 53 | showIcon = true, 54 | showClear = true, 55 | maxResults = MAX_RESULTS, 56 | placeholder = '', 57 | autoFocus = false, 58 | styling = {}, 59 | resultStringKeyName = 'name', 60 | inputSearchString = '', 61 | formatResult, 62 | showNoResults = true, 63 | showNoResultsText = 'No results', 64 | showItemsOnFocus = false, 65 | maxLength = 0, 66 | className 67 | }: ReactSearchAutocompleteProps) { 68 | const theme = { ...defaultTheme, ...styling } 69 | const options = { ...defaultFuseOptions, ...fuseOptions } 70 | 71 | const fuse = new Fuse(items, options) 72 | fuse.setCollection(items) 73 | 74 | const [searchString, setSearchString] = useState(inputSearchString) 75 | const [results, setResults] = useState([]) 76 | const [highlightedItem, setHighlightedItem] = useState(-1) 77 | const [isSearchComplete, setIsSearchComplete] = useState(false) 78 | const [isTyping, setIsTyping] = useState(false) 79 | const [showNoResultsFlag, setShowNoResultsFlag] = useState(false) 80 | const [hasFocus, setHasFocus] = useState(false) 81 | 82 | useEffect(() => { 83 | setSearchString(inputSearchString) 84 | const timeoutId = setTimeout(() => setResults(fuseResults(inputSearchString)), 0) 85 | 86 | return () => clearTimeout(timeoutId) 87 | }, [inputSearchString]) 88 | 89 | useEffect(() => { 90 | searchString?.length > 0 && 91 | results && 92 | results?.length > 0 && 93 | setResults(fuseResults(searchString)) 94 | }, [items]) 95 | 96 | useEffect(() => { 97 | if ( 98 | showNoResults && 99 | searchString.length > 0 && 100 | !isTyping && 101 | results.length === 0 && 102 | !isSearchComplete 103 | ) { 104 | setShowNoResultsFlag(true) 105 | } else { 106 | setShowNoResultsFlag(false) 107 | } 108 | }, [isTyping, showNoResults, isSearchComplete, searchString, results]) 109 | 110 | useEffect(() => { 111 | if (showItemsOnFocus && results.length === 0 && searchString.length === 0 && hasFocus) { 112 | setResults(items.slice(0, maxResults)) 113 | } 114 | }, [showItemsOnFocus, results, searchString, hasFocus]) 115 | 116 | useEffect(() => { 117 | const handleDocumentClick = () => { 118 | eraseResults() 119 | setHasFocus(false) 120 | } 121 | 122 | document.addEventListener('click', handleDocumentClick) 123 | 124 | return () => document.removeEventListener('click', handleDocumentClick) 125 | }, []) 126 | 127 | const handleOnFocus = (event: FocusEvent) => { 128 | onFocus(event) 129 | setHasFocus(true) 130 | } 131 | 132 | const callOnSearch = (keyword: string) => { 133 | let newResults: T[] = [] 134 | 135 | keyword?.length > 0 && (newResults = fuseResults(keyword)) 136 | 137 | setResults(newResults) 138 | onSearch(keyword, newResults) 139 | setIsTyping(false) 140 | } 141 | 142 | const handleOnSearch = React.useCallback( 143 | inputDebounce > 0 144 | ? debounce((keyword: string) => callOnSearch(keyword), inputDebounce) 145 | : (keyword: string) => callOnSearch(keyword), 146 | [items] 147 | ) 148 | 149 | const handleOnClick = (result: Item) => { 150 | eraseResults() 151 | onSelect(result) 152 | setSearchString(result[resultStringKeyName]) 153 | setHighlightedItem(0) 154 | } 155 | 156 | const fuseResults = (keyword: string) => 157 | fuse 158 | .search(keyword, { limit: maxResults }) 159 | .map((result) => ({ ...result.item })) 160 | .slice(0, maxResults) 161 | 162 | const handleSetSearchString = ({ target }: ChangeEvent) => { 163 | const keyword = target.value 164 | 165 | setSearchString(keyword) 166 | handleOnSearch(keyword) 167 | setIsTyping(true) 168 | 169 | if (isSearchComplete) { 170 | setIsSearchComplete(false) 171 | } 172 | } 173 | 174 | const eraseResults = () => { 175 | setResults([]) 176 | setIsSearchComplete(true) 177 | } 178 | 179 | const handleSetHighlightedItem = ({ 180 | index, 181 | event 182 | }: { 183 | index?: number 184 | event?: KeyboardEvent 185 | }) => { 186 | let itemIndex = -1 187 | 188 | const setValues = (index: number) => { 189 | setHighlightedItem(index) 190 | results?.[index] && onHover(results[index]) 191 | } 192 | 193 | if (index !== undefined) { 194 | setHighlightedItem(index) 195 | results?.[index] && onHover(results[index]) 196 | } else if (event) { 197 | switch (event.key) { 198 | case 'Enter': 199 | if (results.length > 0 && results[highlightedItem]) { 200 | event.preventDefault() 201 | onSelect(results[highlightedItem]) 202 | setSearchString(results[highlightedItem][resultStringKeyName]) 203 | onSearch(results[highlightedItem][resultStringKeyName], results) 204 | } else { 205 | onSearch(searchString, results) 206 | } 207 | setHighlightedItem(-1) 208 | eraseResults() 209 | break 210 | case 'ArrowUp': 211 | event.preventDefault() 212 | itemIndex = highlightedItem > -1 ? highlightedItem - 1 : results.length - 1 213 | setValues(itemIndex) 214 | break 215 | case 'ArrowDown': 216 | event.preventDefault() 217 | itemIndex = highlightedItem < results.length - 1 ? highlightedItem + 1 : -1 218 | setValues(itemIndex) 219 | break 220 | default: 221 | break 222 | } 223 | } 224 | } 225 | 226 | return ( 227 | 228 | 229 |
230 | 243 | 256 |
257 |
258 |
259 | ) 260 | } 261 | 262 | const StyledReactSearchAutocomplete = styled.div` 263 | position: relative; 264 | 265 | height: ${(props: any) => parseInt(props.theme.height) + 2 + 'px'}; 266 | 267 | .wrapper { 268 | position: absolute; 269 | display: flex; 270 | flex-direction: column; 271 | width: 100%; 272 | 273 | border: ${(props: any) => props.theme.border}; 274 | border-radius: ${(props: any) => props.theme.borderRadius}; 275 | 276 | background-color: ${(props: any) => props.theme.backgroundColor}; 277 | color: ${(props: any) => props.theme.color}; 278 | 279 | font-size: ${(props: any) => props.theme.fontSize}; 280 | font-family: ${(props: any) => props.theme.fontFamily}; 281 | 282 | z-index: ${(props: any) => props.theme.zIndex}; 283 | 284 | &:hover { 285 | box-shadow: ${(props: any) => props.theme.boxShadow}; 286 | } 287 | &:active { 288 | box-shadow: ${(props: any) => props.theme.boxShadow}; 289 | } 290 | &:focus-within { 291 | box-shadow: ${(props: any) => props.theme.boxShadow}; 292 | } 293 | } 294 | ` 295 | -------------------------------------------------------------------------------- /src/components/Results.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEvent, ReactNode } from 'react' 2 | import styled from 'styled-components' 3 | import { SearchIcon } from './SearchIcon' 4 | 5 | export type Item = T & { [key: string]: any } 6 | 7 | export interface ResultsProps { 8 | results: Item[] 9 | onClick: Function 10 | highlightedItem: number 11 | setHighlightedItem: Function 12 | setSearchString: Function 13 | formatResult?: Function 14 | showIcon: boolean 15 | maxResults: number 16 | resultStringKeyName: string 17 | showNoResultsFlag?: boolean 18 | showNoResultsText?: string 19 | } 20 | 21 | export default function Results({ 22 | results = [] as any, 23 | onClick, 24 | setSearchString, 25 | showIcon, 26 | maxResults, 27 | resultStringKeyName = 'name', 28 | highlightedItem, 29 | setHighlightedItem, 30 | formatResult, 31 | showNoResultsFlag = true, 32 | showNoResultsText = 'No results' 33 | }: ResultsProps) { 34 | type WithStringKeyName = T & Record 35 | 36 | const formatResultWithKey = formatResult 37 | ? formatResult 38 | : (item: WithStringKeyName) => item[resultStringKeyName] 39 | 40 | const handleClick = (result: WithStringKeyName) => { 41 | onClick(result) 42 | setSearchString(result[resultStringKeyName]) 43 | } 44 | 45 | const handleMouseDown = ({ 46 | event, 47 | result 48 | }: { 49 | event: MouseEvent 50 | result: WithStringKeyName 51 | }) => { 52 | if (event.button === 0) { 53 | event.preventDefault() 54 | handleClick(result) 55 | } 56 | } 57 | 58 | if (showNoResultsFlag) { 59 | return ( 60 | 61 |
  • 62 | 63 |
    {showNoResultsText}
    64 |
  • 65 |
    66 | ) 67 | } 68 | 69 | if (results?.length <= 0 && !showNoResultsFlag) { 70 | return null 71 | } 72 | 73 | return ( 74 | 75 | {results.slice(0, maxResults).map((result, index) => ( 76 |
  • setHighlightedItem({ index })} 79 | data-test="result" 80 | key={`rsa-result-${result.id}`} 81 | onMouseDown={(event) => handleMouseDown({ event, result })} 82 | onClick={() => handleClick(result)} 83 | > 84 | 85 |
    86 | {formatResultWithKey(result)} 87 |
    88 |
  • 89 | ))} 90 |
    91 | ) 92 | } 93 | 94 | const ResultsWrapper = ({ children }: { children: ReactNode }) => { 95 | return ( 96 | 97 |
    98 |
      {children}
    99 | 100 | ) 101 | } 102 | 103 | const StyledResults = styled.div` 104 | > div.line { 105 | border-top-color: ${(props: any) => props.theme.lineColor}; 106 | border-top-style: solid; 107 | border-top-width: 1px; 108 | 109 | margin-bottom: 0px; 110 | margin-left: 14px; 111 | margin-right: 20px; 112 | margin-top: 0px; 113 | 114 | padding-bottom: 4px; 115 | } 116 | 117 | > ul { 118 | list-style-type: none; 119 | margin: 0; 120 | padding: 0px 0 16px 0; 121 | max-height: ${(props: any) => props.theme.maxHeight}; 122 | 123 | > li { 124 | display: flex; 125 | align-items: center; 126 | padding: 4px 0 4px 0; 127 | 128 | > div { 129 | margin-left: 13px; 130 | } 131 | } 132 | } 133 | 134 | .ellipsis { 135 | text-align: left; 136 | width: 100%; 137 | white-space: nowrap; 138 | overflow: hidden; 139 | text-overflow: ellipsis; 140 | } 141 | 142 | .selected { 143 | background-color: ${(props: any) => props.theme.hoverBackgroundColor}; 144 | } 145 | ` 146 | -------------------------------------------------------------------------------- /src/components/SearchIcon.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const SearchIcon = ({ showIcon }: { showIcon: boolean }) => { 4 | if (!showIcon) { 5 | return null 6 | } 7 | 8 | return ( 9 | 17 | 18 | 19 | ) 20 | } 21 | 22 | const StyledSearchIcon = styled.svg` 23 | flex-shrink: 0; 24 | margin: ${(props: any) => props.theme.searchIconMargin}; 25 | fill: ${(props: any) => props.theme.iconColor}; 26 | ` 27 | -------------------------------------------------------------------------------- /src/components/SearchInput.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEventHandler, FocusEvent, FocusEventHandler, useRef } from 'react' 2 | import styled from 'styled-components' 3 | import { ClearIcon } from './ClearIcon' 4 | import { SearchIcon } from './SearchIcon' 5 | 6 | interface SearchInputProps { 7 | searchString: string 8 | setSearchString: ChangeEventHandler 9 | setHighlightedItem: Function 10 | eraseResults: Function 11 | autoFocus: boolean 12 | onFocus: FocusEventHandler 13 | onClear: Function 14 | placeholder: string 15 | showIcon: boolean 16 | showClear: boolean 17 | maxLength: number 18 | } 19 | 20 | export default function SearchInput({ 21 | searchString, 22 | setSearchString, 23 | setHighlightedItem, 24 | eraseResults, 25 | autoFocus, 26 | onFocus, 27 | onClear, 28 | placeholder, 29 | showIcon = true, 30 | showClear = true, 31 | maxLength 32 | }: SearchInputProps) { 33 | const ref = useRef(null) 34 | 35 | let manualFocus = true 36 | 37 | const setFocus = () => { 38 | manualFocus = false 39 | ref?.current && ref.current.focus() 40 | manualFocus = true 41 | } 42 | 43 | const handleOnFocus = (event: FocusEvent) => { 44 | manualFocus && onFocus(event) 45 | } 46 | 47 | const maxLengthProperty = maxLength ? { maxLength } : {} 48 | 49 | return ( 50 | 51 | 52 | eraseResults()} 62 | onKeyDown={(event) => setHighlightedItem({ event })} 63 | data-test="search-input" 64 | {...maxLengthProperty} 65 | /> 66 | 73 | 74 | ) 75 | } 76 | 77 | const StyledSearchInput = styled.div` 78 | min-height: ${(props: any) => props.theme.height}; 79 | width: 100%; 80 | 81 | display: flex; 82 | align-items: center; 83 | 84 | > input { 85 | width: 100%; 86 | 87 | padding: 0 0 0 13px; 88 | 89 | border: none; 90 | outline: none; 91 | 92 | background-color: rgba(0, 0, 0, 0); 93 | font-size: inherit; 94 | font-family: inherit; 95 | 96 | color: ${(props: any) => props.theme.color}; 97 | 98 | ::placeholder { 99 | color: ${(props: any) => props.theme.placeholderColor}; 100 | opacity: 1; 101 | 102 | :-ms-input-placeholder { 103 | color: ${(props: any) => props.theme.placeholderColor}; 104 | } 105 | 106 | ::-ms-input-placeholder { 107 | color: ${(props: any) => props.theme.placeholderColor}; 108 | } 109 | } 110 | } 111 | ` 112 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | import Fuse from 'fuse.js' 2 | 3 | export interface DefaultTheme { 4 | height?: string 5 | border?: string 6 | borderRadius?: string 7 | backgroundColor?: string 8 | boxShadow?: string 9 | hoverBackgroundColor?: string 10 | color?: string 11 | fontSize?: string 12 | fontFamily?: string 13 | iconColor?: string 14 | lineColor?: string 15 | placeholderColor?: string 16 | zIndex?: number 17 | clearIconMargin?: string 18 | searchIconMargin?: string 19 | } 20 | 21 | const defaultTheme: DefaultTheme = { 22 | height: '44px', 23 | border: '1px solid #dfe1e5', 24 | borderRadius: '24px', 25 | backgroundColor: 'white', 26 | boxShadow: 'rgba(32, 33, 36, 0.28) 0px 1px 6px 0px', 27 | hoverBackgroundColor: '#eee', 28 | color: '#212121', 29 | fontSize: '16px', 30 | fontFamily: 'Arial', 31 | iconColor: 'grey', 32 | lineColor: 'rgb(232, 234, 237)', 33 | placeholderColor: 'grey', 34 | zIndex: 0, 35 | clearIconMargin: '3px 14px 0 0', 36 | searchIconMargin: '0 0 0 16px' 37 | } 38 | 39 | const defaultFuseOptions: Fuse.IFuseOptions = { 40 | shouldSort: true, 41 | threshold: 0.6, 42 | location: 0, 43 | distance: 100, 44 | minMatchCharLength: 1, 45 | keys: ['name'] 46 | } 47 | 48 | export { defaultTheme, defaultFuseOptions } 49 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import ReactSearchAutocomplete from './components/ReactSearchAutocomplete.js' 2 | 3 | export { ReactSearchAutocomplete } 4 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export function debounce(func: Function, wait: number, immediate?: boolean) { 2 | let timeout: NodeJS.Timeout | null 3 | 4 | return function (this: any) { 5 | const context = this 6 | const args = arguments 7 | 8 | const later = function () { 9 | timeout = null 10 | if (!immediate) func.apply(context, args) 11 | } 12 | 13 | if (immediate && !timeout) func.apply(context, args) 14 | 15 | timeout && clearTimeout(timeout) 16 | timeout = setTimeout(later, wait) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/ReactSearchAutocomplete.test.tsx: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill' 2 | import '@testing-library/jest-dom/extend-expect' 3 | import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' 4 | import React from 'react' 5 | import ReactSearchAutocomplete, { 6 | DEFAULT_INPUT_DEBOUNCE, 7 | ReactSearchAutocompleteProps 8 | } from '../src/components/ReactSearchAutocomplete' 9 | 10 | beforeEach(() => { 11 | localStorage.clear() 12 | }) 13 | 14 | afterEach(() => { 15 | cleanup() 16 | jest.clearAllMocks() 17 | }) 18 | 19 | interface Item { 20 | id: number 21 | name: string 22 | } 23 | 24 | interface ItemWithDescription { 25 | id: number 26 | title: string 27 | description: string 28 | } 29 | 30 | describe('', () => { 31 | let items = [ 32 | { 33 | id: 0, 34 | name: 'value0' 35 | }, 36 | { 37 | id: 1, 38 | name: 'value1' 39 | }, 40 | { 41 | id: 2, 42 | name: 'value2' 43 | }, 44 | { 45 | id: 3, 46 | name: 'value3' 47 | } 48 | ] 49 | 50 | let defaultProps: ReactSearchAutocompleteProps = { 51 | items, 52 | placeholder: 'Search' 53 | } 54 | 55 | let onSearch = jest.fn() 56 | 57 | beforeEach(() => jest.useFakeTimers()) 58 | 59 | afterEach(() => { 60 | jest.clearAllMocks() 61 | }) 62 | 63 | function proceed() { 64 | act(() => { 65 | jest.advanceTimersByTime(DEFAULT_INPUT_DEBOUNCE) 66 | }) 67 | } 68 | 69 | it('renders the search box', () => { 70 | const { queryByPlaceholderText, container } = render( 71 | {...defaultProps} /> 72 | ) 73 | 74 | const inputElement = queryByPlaceholderText(/search/i) 75 | 76 | expect(inputElement!).toBeInTheDocument() 77 | expect(container.querySelectorAll('.search-icon').length).toBe(1) 78 | expect(container.getElementsByClassName('wrapper').length).toBe(1) 79 | }) 80 | 81 | it('applies the custom class to the component', () => { 82 | const { container } = render( 83 | {...defaultProps} className="search" /> 84 | ) 85 | 86 | expect(container.getElementsByClassName('search')[0]).not.toBeNull() 87 | }) 88 | 89 | it('uses inputSearchString prop', async () => { 90 | const { queryByPlaceholderText } = render( 91 | {...defaultProps} inputSearchString="a string" /> 92 | ) 93 | 94 | const inputElement = queryByPlaceholderText(/search/i) 95 | 96 | expect(inputElement!).toHaveValue('a string') 97 | }) 98 | 99 | it('updates the value if inputSearchString prop is updated', async () => { 100 | const { rerender, queryByPlaceholderText } = render( 101 | {...defaultProps} inputSearchString="a string" /> 102 | ) 103 | 104 | expect(queryByPlaceholderText(/search/i)).toHaveValue('a string') 105 | 106 | rerender( {...defaultProps} inputSearchString="a new string" />) 107 | 108 | expect(queryByPlaceholderText(/search/i)).toHaveValue('a new string') 109 | }) 110 | 111 | it('display results if inputSearchString prop changes', async () => { 112 | const { queryByPlaceholderText, queryAllByText } = render( 113 | 114 | {...defaultProps} 115 | items={[...items, { id: 4, name: 'some other' }]} 116 | inputSearchString="value0" 117 | /> 118 | ) 119 | 120 | proceed() 121 | 122 | expect(queryByPlaceholderText(/search/i)).toHaveValue('value0') 123 | 124 | const results = queryAllByText('value0') 125 | 126 | expect(results.length).toBe(1) 127 | 128 | const notDisplayedResults = queryAllByText('some other') 129 | 130 | expect(notDisplayedResults.length).toBe(0) 131 | }) 132 | 133 | it('updates results if items change', async () => { 134 | const { rerender } = render( 135 | {...defaultProps} onSearch={onSearch} /> 136 | ) 137 | 138 | const inputElement = screen.queryByPlaceholderText(/search/i) 139 | 140 | fireEvent.change(inputElement!, { target: { value: 'value' } }) 141 | 142 | proceed() 143 | 144 | expect(onSearch).toHaveBeenCalledWith('value', items) 145 | 146 | const newItems = [ 147 | { 148 | id: 0, 149 | name: 'another0' 150 | }, 151 | { 152 | id: 1, 153 | name: 'another1' 154 | }, 155 | { 156 | id: 2, 157 | name: 'another2' 158 | }, 159 | { 160 | id: 3, 161 | name: 'another3' 162 | } 163 | ] 164 | 165 | onSearch.mockClear() 166 | 167 | rerender( 168 | {...defaultProps} items={newItems} onSearch={onSearch} /> 169 | ) 170 | 171 | fireEvent.change(inputElement!, { target: { value: 'another' } }) 172 | 173 | proceed() 174 | 175 | expect(onSearch).toHaveBeenCalledWith('another', newItems) 176 | }) 177 | 178 | it('returns an array of results', async () => { 179 | const { queryByPlaceholderText } = render( 180 | {...defaultProps} onSearch={onSearch} /> 181 | ) 182 | 183 | const inputElement = queryByPlaceholderText(/search/i) 184 | 185 | fireEvent.change(inputElement!, { target: { value: 'value' } }) 186 | 187 | proceed() 188 | 189 | expect(onSearch).toHaveBeenCalledWith('value', items) 190 | }) 191 | 192 | it('returns the items on focus if showItemsOnFocus is true', async () => { 193 | const { queryByPlaceholderText, container } = render( 194 | {...defaultProps} showItemsOnFocus={true} /> 195 | ) 196 | 197 | const inputElement = queryByPlaceholderText(/search/i) 198 | 199 | fireEvent.focusIn(inputElement!) 200 | 201 | const liTags = container.getElementsByTagName('li') 202 | 203 | expect(liTags.length).toBe(4) 204 | }) 205 | 206 | it('limits the items on focus if showItemsOnFocus is true to maxResults', async () => { 207 | const { queryByPlaceholderText, container } = render( 208 | {...defaultProps} showItemsOnFocus={true} maxResults={2} /> 209 | ) 210 | 211 | const inputElement = queryByPlaceholderText(/search/i) 212 | 213 | fireEvent.focusIn(inputElement!) 214 | 215 | const liTags = container.getElementsByTagName('li') 216 | 217 | expect(liTags.length).toBe(2) 218 | }) 219 | 220 | it('returns by default the list of items after clearing the input box if showItemsOnFocus is true', async () => { 221 | const newItems = [ 222 | { 223 | id: 0, 224 | name: 'aaa' 225 | }, 226 | { 227 | id: 1, 228 | name: 'bbb' 229 | }, 230 | { 231 | id: 2, 232 | name: 'ccc' 233 | }, 234 | { 235 | id: 3, 236 | name: 'ddd' 237 | } 238 | ] 239 | 240 | const { queryByPlaceholderText, container } = render( 241 | 242 | {...defaultProps} 243 | onSearch={onSearch} 244 | showItemsOnFocus={true} 245 | items={newItems} 246 | /> 247 | ) 248 | 249 | const inputElement = queryByPlaceholderText(/search/i) 250 | 251 | fireEvent.focusIn(inputElement!) 252 | 253 | let liTags = container.getElementsByTagName('li') 254 | 255 | expect(liTags.length).toBe(4) 256 | 257 | fireEvent.change(inputElement!, { target: { value: 'aaa' } }) 258 | 259 | proceed() 260 | 261 | expect(onSearch).toHaveBeenCalledWith('aaa', [{ id: 0, name: 'aaa' }]) 262 | 263 | liTags = container.getElementsByTagName('li') 264 | 265 | expect(liTags.length).toBe(1) 266 | 267 | fireEvent.change(inputElement!, { target: { value: '' } }) 268 | 269 | proceed() 270 | 271 | liTags = container.getElementsByTagName('li') 272 | 273 | expect(liTags.length).toBe(4) 274 | }) 275 | 276 | it('returns an array of one result', async () => { 277 | const { queryByPlaceholderText } = render( 278 | {...defaultProps} onSearch={onSearch} /> 279 | ) 280 | 281 | const inputElement = queryByPlaceholderText(/search/i) 282 | 283 | fireEvent.change(inputElement!, { target: { value: '0' } }) 284 | 285 | proceed() 286 | 287 | expect(onSearch).toHaveBeenCalledWith('0', [{ id: 0, name: 'value0' }]) 288 | }) 289 | 290 | it('calls onSearch on change', async () => { 291 | const { queryByPlaceholderText } = render( 292 | {...defaultProps} onSearch={onSearch} /> 293 | ) 294 | 295 | const inputElement = queryByPlaceholderText(/search/i) 296 | 297 | fireEvent.change(inputElement!, { target: { value: 'v' } }) 298 | 299 | proceed() 300 | 301 | expect(onSearch).toHaveBeenCalledWith('v', items) 302 | }) 303 | 304 | it('calls onSearch and delete results if text is empty', async () => { 305 | const { queryByPlaceholderText } = render( 306 | {...defaultProps} onSearch={onSearch} /> 307 | ) 308 | 309 | const inputElement = queryByPlaceholderText(/search/i) 310 | fireEvent.change(inputElement!, { target: { value: 'v' } }) 311 | proceed() 312 | 313 | expect(onSearch).toHaveBeenNthCalledWith(1, 'v', items) 314 | 315 | fireEvent.change(inputElement!, { target: { value: '' } }) 316 | proceed() 317 | 318 | expect(onSearch).toHaveBeenNthCalledWith(2, '', []) 319 | }) 320 | 321 | it('calls onHover when result is hovered', () => { 322 | const onHover = jest.fn() 323 | 324 | const { container, queryByPlaceholderText } = render( 325 | {...defaultProps} onHover={onHover} /> 326 | ) 327 | 328 | const inputElement = queryByPlaceholderText(/search/i) 329 | fireEvent.change(inputElement!, { target: { value: 'v' } }) 330 | proceed() 331 | 332 | const liTag = container.getElementsByTagName('li')[0] 333 | 334 | fireEvent.mouseEnter(liTag) 335 | 336 | expect(onHover).toHaveBeenCalledWith(items[0]) 337 | }) 338 | 339 | it('does not call onHover when using arrows and no results are visible', () => { 340 | const onHover = jest.fn() 341 | 342 | const { queryByPlaceholderText } = render( 343 | {...defaultProps} onHover={onHover} /> 344 | ) 345 | 346 | const inputElement = queryByPlaceholderText(/search/i) 347 | 348 | for (let i = 0; i < 10; i++) { 349 | fireEvent.keyDown(inputElement!, { 350 | key: 'ArrowDown', 351 | code: 'ArrowDown', 352 | keyCode: 40, 353 | charCode: 40 354 | }) 355 | 356 | proceed() 357 | 358 | expect(onHover).not.toHaveBeenCalled() 359 | } 360 | }) 361 | 362 | it('change selected element when ArrowDown is pressed', () => { 363 | const onHover = jest.fn() 364 | 365 | const { container, queryByPlaceholderText } = render( 366 | {...defaultProps} onHover={onHover} /> 367 | ) 368 | 369 | const inputElement = queryByPlaceholderText(/search/i) 370 | fireEvent.change(inputElement!, { target: { value: 'v' } }) 371 | proceed() 372 | 373 | fireEvent.keyDown(inputElement!, { 374 | key: 'ArrowDown', 375 | code: 'ArrowDown', 376 | keyCode: 40, 377 | charCode: 40 378 | }) 379 | 380 | const liTag0 = container.getElementsByTagName('li')[0] 381 | expect(liTag0).toHaveClass('selected') 382 | 383 | fireEvent.keyDown(inputElement!, { 384 | key: 'ArrowDown', 385 | code: 'ArrowDown', 386 | keyCode: 40, 387 | charCode: 40 388 | }) 389 | 390 | proceed() 391 | expect(liTag0).not.toHaveClass('selected') 392 | 393 | const liTag1 = container.getElementsByTagName('li')[1] 394 | expect(liTag1).toHaveClass('selected') 395 | }) 396 | 397 | it('calls onSelect when key navigating down and pressing return', () => { 398 | const onSelect = jest.fn() 399 | 400 | const { queryByPlaceholderText } = render( 401 | {...defaultProps} onSelect={onSelect} /> 402 | ) 403 | 404 | const inputElement = queryByPlaceholderText(/search/i) 405 | 406 | fireEvent.change(inputElement!, { target: { value: 'v' } }) 407 | 408 | proceed() 409 | 410 | fireEvent.keyDown(inputElement!, { 411 | key: 'ArrowDown', 412 | code: 'ArrowDown', 413 | keyCode: 40, 414 | charCode: 40 415 | }) 416 | 417 | fireEvent.keyDown(inputElement!, { 418 | key: 'ArrowDown', 419 | code: 'ArrowDown', 420 | keyCode: 40, 421 | charCode: 40 422 | }) 423 | 424 | proceed() 425 | 426 | fireEvent.keyDown(inputElement!, { 427 | key: 'Enter', 428 | code: 'Enter', 429 | keyCode: 13, 430 | charCode: 13 431 | }) 432 | 433 | expect(onSelect).toHaveBeenCalledWith(items[1]) 434 | }) 435 | 436 | it('calls onSearch when key navigating and pressing return when no eleemnt is highlighted', () => { 437 | const onSearch = jest.fn() 438 | 439 | const { queryByPlaceholderText } = render( 440 | {...defaultProps} onSearch={onSearch} /> 441 | ) 442 | 443 | const inputElement = queryByPlaceholderText(/search/i) 444 | 445 | const searchString = 'val' 446 | 447 | fireEvent.change(inputElement!, { target: { value: searchString } }) 448 | 449 | proceed() 450 | 451 | for (let i = 0; i < items.length + 1; i++) { 452 | fireEvent.keyDown(inputElement!, { 453 | key: 'ArrowDown', 454 | code: 'ArrowDown', 455 | keyCode: 40, 456 | charCode: 40 457 | }) 458 | 459 | proceed() 460 | } 461 | 462 | proceed() 463 | 464 | fireEvent.keyDown(inputElement!, { 465 | key: 'Enter', 466 | code: 'Enter', 467 | keyCode: 13, 468 | charCode: 13 469 | }) 470 | 471 | expect(onSearch).toHaveBeenCalledWith(searchString, defaultProps.items) 472 | }) 473 | 474 | it('sets the value of the input to the selected item when pressing return', () => { 475 | const { queryByPlaceholderText } = render( {...defaultProps} />) 476 | 477 | const inputElement = queryByPlaceholderText(/search/i) 478 | 479 | fireEvent.change(inputElement!, { target: { value: 'v' } }) 480 | 481 | proceed() 482 | 483 | fireEvent.keyDown(inputElement!, { 484 | key: 'ArrowDown', 485 | code: 'ArrowDown', 486 | keyCode: 40, 487 | charCode: 40 488 | }) 489 | 490 | fireEvent.keyDown(inputElement!, { 491 | key: 'ArrowDown', 492 | code: 'ArrowDown', 493 | keyCode: 40, 494 | charCode: 40 495 | }) 496 | 497 | proceed() 498 | 499 | fireEvent.keyDown(inputElement!, { 500 | key: 'Enter', 501 | code: 'Enter', 502 | keyCode: 13, 503 | charCode: 13 504 | }) 505 | 506 | expect(inputElement!).toHaveDisplayValue(items[1].name) 507 | }) 508 | 509 | it('sets the value of the input to the selected item when clicking on it', () => { 510 | const { container, queryByPlaceholderText } = render( 511 | {...defaultProps} /> 512 | ) 513 | 514 | const inputElement = queryByPlaceholderText(/search/i) 515 | fireEvent.change(inputElement!, { target: { value: 'v' } }) 516 | proceed() 517 | 518 | const liTag = container.getElementsByTagName('li')[0] 519 | fireEvent.click(liTag) 520 | 521 | expect(inputElement!).toHaveDisplayValue(items[0].name) 522 | }) 523 | 524 | it('calls onSelect when ciclying and pressing return', () => { 525 | const onSelect = jest.fn() 526 | 527 | const { queryByPlaceholderText } = render( 528 | {...defaultProps} onSelect={onSelect} /> 529 | ) 530 | 531 | const inputElement = queryByPlaceholderText(/search/i) 532 | 533 | fireEvent.change(inputElement!, { target: { value: 'v' } }) 534 | proceed() 535 | 536 | for (let i = 0; i < items.length + 2; i++) { 537 | fireEvent.keyDown(inputElement!, { 538 | key: 'ArrowDown', 539 | code: 'ArrowDown', 540 | keyCode: 40, 541 | charCode: 40 542 | }) 543 | 544 | proceed() 545 | } 546 | 547 | fireEvent.keyDown(inputElement!, { 548 | key: 'Enter', 549 | code: 'Enter', 550 | keyCode: 13, 551 | charCode: 13 552 | }) 553 | 554 | expect(onSelect).toHaveBeenCalledWith(items[0]) 555 | }) 556 | 557 | it('calls onSelect when clicking on item', () => { 558 | const onSelect = jest.fn() 559 | 560 | const { queryByPlaceholderText, queryAllByTitle, queryByText, container } = render( 561 | {...defaultProps} onSelect={onSelect} /> 562 | ) 563 | 564 | let inputElement = queryByPlaceholderText(/search/i) as HTMLInputElement 565 | 566 | fireEvent.change(inputElement!, { target: { value: 'v' } }) 567 | 568 | proceed() 569 | 570 | const liNode = queryAllByTitle('value0')[0] 571 | 572 | fireEvent.click(liNode) 573 | 574 | expect(onSelect).toHaveBeenCalledWith({ id: 0, name: 'value0' }) 575 | 576 | expect(inputElement!.value).toBe('value0') 577 | }) 578 | 579 | it('does not display results again after selection if items changes', () => { 580 | const onSelect = jest.fn() 581 | 582 | const { queryByPlaceholderText, queryAllByTitle, container } = render( 583 | {...defaultProps} onSelect={onSelect} /> 584 | ) 585 | 586 | const inputElement = queryByPlaceholderText(/search/i) as HTMLInputElement 587 | 588 | fireEvent.change(inputElement!, { target: { value: 'v' } }) 589 | 590 | proceed() 591 | 592 | const liElement = queryAllByTitle('value0')[0] 593 | 594 | fireEvent.click(liElement) 595 | 596 | expect(onSelect).toHaveBeenCalledWith({ id: 0, name: 'value0' }) 597 | 598 | expect(inputElement!.value).toBe('value0') 599 | 600 | const newItems = [ 601 | { 602 | id: 0, 603 | name: 'another0' 604 | }, 605 | { 606 | id: 1, 607 | name: 'another1' 608 | }, 609 | { 610 | id: 2, 611 | name: 'another2' 612 | }, 613 | { 614 | id: 3, 615 | name: 'another3' 616 | } 617 | ] 618 | 619 | render( 620 | {...defaultProps} items={newItems} onSelect={onSelect} />, 621 | { 622 | container 623 | } 624 | ) 625 | 626 | const liElements = container.querySelectorAll('[data-test="result"]') 627 | 628 | expect(liElements.length).toBe(0) 629 | }) 630 | 631 | it('calls onFocus on input focus', () => { 632 | const onFocus = jest.fn() 633 | 634 | const { queryByPlaceholderText } = render( 635 | {...defaultProps} onFocus={onFocus} /> 636 | ) 637 | 638 | const inputElement = queryByPlaceholderText(/search/i) 639 | 640 | fireEvent.focus(inputElement!) 641 | 642 | expect(onFocus).toHaveBeenCalled() 643 | }) 644 | 645 | it('sets focus if autoFocus is true', () => { 646 | const { queryByPlaceholderText } = render( 647 | {...defaultProps} autoFocus={true} /> 648 | ) 649 | 650 | const inputElement = queryByPlaceholderText(/search/i) 651 | 652 | expect(inputElement!).toHaveFocus() 653 | }) 654 | 655 | it('uses debounce on search', () => { 656 | const { queryByPlaceholderText } = render( 657 | {...defaultProps} onSearch={onSearch} /> 658 | ) 659 | 660 | const inputElement = queryByPlaceholderText(/search/i) 661 | 662 | for (let i = 0; i < 10; i++) { 663 | fireEvent.change(inputElement!, { target: { value: Math.random() } }) 664 | } 665 | 666 | proceed() 667 | 668 | expect(onSearch).toBeCalledTimes(1) 669 | }) 670 | 671 | it("doesn't use debounce if inputDebounce is 0", () => { 672 | const { queryByPlaceholderText } = render( 673 | {...defaultProps} onSearch={onSearch} inputDebounce={0} /> 674 | ) 675 | 676 | onSearch.mockClear() 677 | 678 | const inputElement = queryByPlaceholderText(/search/i) 679 | 680 | for (let i = 0; i < 10; i++) { 681 | fireEvent.change(inputElement!, { target: { value: Math.random() } }) 682 | } 683 | 684 | expect(onSearch).toBeCalledTimes(10) 685 | }) 686 | 687 | describe('with items with name property', () => { 688 | it('renders the search box', () => { 689 | const { queryByPlaceholderText, container } = render( 690 | {...defaultProps} /> 691 | ) 692 | const inputElement = queryByPlaceholderText(/search/i) 693 | // check that the input node is present 694 | expect(inputElement!).toBeInTheDocument() 695 | // check that the icon is present 696 | expect(container.querySelectorAll('.search-icon').length).toBe(1) 697 | // check that wrapper div is present 698 | expect(container.getElementsByClassName('wrapper').length).toBe(1) 699 | }) 700 | 701 | it('shows 4 matching items', () => { 702 | const { queryByPlaceholderText, container } = render( 703 | {...defaultProps} /> 704 | ) 705 | 706 | const inputElement = queryByPlaceholderText(/search/i) 707 | 708 | fireEvent.change(inputElement!, { target: { value: 'v' } }) 709 | 710 | proceed() 711 | 712 | const ul = container.getElementsByTagName('ul')[0] 713 | expect(ul.getElementsByTagName('li').length).toBe(4) 714 | expect(ul.querySelectorAll('.search-icon').length).toBe(4) 715 | }) 716 | 717 | it('shows 1 matching item', () => { 718 | const { queryByPlaceholderText, queryAllByTitle, container } = render( 719 | {...defaultProps} /> 720 | ) 721 | 722 | const inputElement = queryByPlaceholderText(/search/i) 723 | 724 | fireEvent.change(inputElement!, { target: { value: '0' } }) 725 | 726 | proceed() 727 | 728 | expect(queryAllByTitle('value0').length).toBe(1) 729 | const ul = container.getElementsByTagName('ul')[0] 730 | expect(ul.getElementsByTagName('li').length).toBe(1) 731 | expect(ul.querySelectorAll('.search-icon').length).toBe(1) 732 | }) 733 | 734 | it('by default shows no results message if there are no matching items', () => { 735 | const { queryByPlaceholderText, container } = render( 736 | {...defaultProps} /> 737 | ) 738 | 739 | const inputElement = queryByPlaceholderText(/search/i) 740 | 741 | fireEvent.change(inputElement!, { target: { value: 'something' } }) 742 | 743 | proceed() 744 | 745 | let liElements = container.querySelectorAll('[data-test="result"]') 746 | 747 | expect(liElements.length).toBe(0) 748 | 749 | liElements = container.querySelectorAll('[data-test="no-results-message"]') 750 | 751 | expect(liElements.length).toBe(1) 752 | expect(liElements[0].textContent).toBe('No results') 753 | }) 754 | 755 | it('shows nothing if showNoResults is false', () => { 756 | const { queryByPlaceholderText, container } = render( 757 | {...defaultProps} showNoResults={false} /> 758 | ) 759 | 760 | const inputElement = queryByPlaceholderText(/search/i) 761 | 762 | fireEvent.change(inputElement!, { target: { value: 'something' } }) 763 | 764 | proceed() 765 | 766 | let liElements = container.querySelectorAll('[data-test="result"]') 767 | 768 | expect(liElements.length).toBe(0) 769 | 770 | liElements = container.querySelectorAll('[data-test="no-results-message"]') 771 | 772 | expect(liElements.length).toBe(0) 773 | }) 774 | 775 | it('shows custom no results message if no results are found', () => { 776 | const { queryByPlaceholderText, container } = render( 777 | 778 | {...defaultProps} 779 | showNoResultsText="We could not find any matching items" 780 | /> 781 | ) 782 | 783 | const inputElement = queryByPlaceholderText(/search/i) 784 | 785 | fireEvent.change(inputElement!, { target: { value: 'something' } }) 786 | 787 | proceed() 788 | 789 | let liElements = container.querySelectorAll('[data-test="result"]') 790 | 791 | expect(liElements.length).toBe(0) 792 | 793 | liElements = container.querySelectorAll('[data-test="no-results-message"]') 794 | 795 | expect(liElements.length).toBe(1) 796 | expect(liElements[0].textContent).toBe('We could not find any matching items') 797 | }) 798 | 799 | it('hides results when input loses focus', () => { 800 | const { queryByPlaceholderText, container } = render( 801 | <> 802 | 803 | {...defaultProps} /> 804 | 805 | ) 806 | 807 | const inputElement = queryByPlaceholderText(/search/i) 808 | 809 | fireEvent.change(inputElement!, { target: { value: 'v' } }) 810 | 811 | proceed() 812 | 813 | let ul = container.getElementsByTagName('ul')[0] 814 | expect(ul.getElementsByTagName('li').length).toBe(4) 815 | expect(ul.querySelectorAll('.search-icon').length).toBe(4) 816 | 817 | const anotherInput = queryByPlaceholderText(/anotherInput/i) 818 | 819 | fireEvent.click(anotherInput!) 820 | 821 | expect(container.getElementsByTagName('ul').length).toBe(0) 822 | }) 823 | }) 824 | 825 | describe('with items with custom properties property', () => { 826 | const items = [ 827 | { 828 | id: 0, 829 | title: 'Titanic', 830 | description: 'A movie about love' 831 | }, 832 | { 833 | id: 1, 834 | title: 'Dead Poets Society', 835 | description: 'A movie about poetry and the meaning of life' 836 | }, 837 | { 838 | id: 2, 839 | title: 'Terminator 2', 840 | description: 'A robot from the future is sent back in time' 841 | }, 842 | { 843 | id: 3, 844 | title: 'Alien 2', 845 | description: 'Ripley is back for a new adventure' 846 | } 847 | ] 848 | 849 | const defaultProps = { 850 | items: items, 851 | placeholder: 'Search', 852 | onSearch: () => {}, 853 | fuseOptions: { keys: ['title', 'description'] }, 854 | resultStringKeyName: 'title' 855 | } 856 | 857 | it('shows 4 matching items', () => { 858 | const { queryByPlaceholderText, container } = render( 859 | {...defaultProps} /> 860 | ) 861 | 862 | const inputElement = queryByPlaceholderText(/search/i) 863 | 864 | fireEvent.change(inputElement!, { target: { value: 'a' } }) 865 | 866 | proceed() 867 | 868 | const ul = container.getElementsByTagName('ul')[0] 869 | expect(ul.getElementsByTagName('li').length).toBe(4) 870 | expect(ul.querySelectorAll('.search-icon').length).toBe(4) 871 | }) 872 | 873 | it('shows 1 matching item', () => { 874 | const { queryByPlaceholderText, queryAllByTitle, container } = render( 875 | {...defaultProps} /> 876 | ) 877 | 878 | const inputElement = queryByPlaceholderText(/search/i) 879 | 880 | fireEvent.change(inputElement!, { target: { value: 'dead' } }) 881 | 882 | proceed() 883 | 884 | expect(queryAllByTitle('Dead Poets Society').length).toBe(1) 885 | const ul = container.getElementsByTagName('ul')[0] 886 | expect(ul.getElementsByTagName('li').length).toBe(1) 887 | expect(ul.querySelectorAll('.search-icon').length).toBe(1) 888 | }) 889 | 890 | it('shows 0 matching item', () => { 891 | const { queryByPlaceholderText, container } = render( 892 | {...defaultProps} /> 893 | ) 894 | 895 | const inputElement = queryByPlaceholderText(/search/i) 896 | 897 | fireEvent.change(inputElement!, { target: { value: 'despaira' } }) 898 | 899 | proceed() 900 | 901 | let liElements = container.querySelectorAll('[data-test="result"]') 902 | 903 | expect(liElements.length).toBe(0) 904 | 905 | liElements = container.querySelectorAll('[data-test="no-results-message"]') 906 | 907 | expect(liElements.length).toBe(1) 908 | }) 909 | }) 910 | 911 | describe('with many items', () => { 912 | const items = [...new Array(10000)].map((_, i) => { 913 | return { 914 | id: i, 915 | title: `something${i}`, 916 | description: 917 | 'A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array. Calls a defined callback function on each element of an array, and returns an array that contains the results.' 918 | } 919 | }) 920 | 921 | const defaultProps = { 922 | items: items, 923 | placeholder: 'Search', 924 | onSearch: () => {}, 925 | fuseOptions: { keys: ['title', 'description'] }, 926 | resultStringKeyName: 'title' 927 | } 928 | 929 | it('renders and display resulst', () => { 930 | const { queryByPlaceholderText, container } = render( 931 | {...defaultProps} /> 932 | ) 933 | 934 | const inputElement = queryByPlaceholderText(/search/i) 935 | 936 | fireEvent.change(inputElement!, { target: { value: 'something' } }) 937 | 938 | proceed() 939 | 940 | const ul = container.getElementsByTagName('ul')[0] 941 | expect(ul.getElementsByTagName('li').length).toBe(10) 942 | expect(ul.querySelectorAll('.search-icon').length).toBe(10) 943 | }) 944 | }) 945 | 946 | describe('showClear', () => { 947 | it('displays the showClear by default', () => { 948 | const { queryByPlaceholderText, container } = render( 949 | {...defaultProps} /> 950 | ) 951 | const inputElement = queryByPlaceholderText(/search/i) 952 | 953 | fireEvent.change(inputElement!, { target: { value: 'something' } }) 954 | 955 | proceed() 956 | 957 | const clearIcon = container.querySelector('.clear-icon') 958 | expect(clearIcon).toBeInTheDocument() 959 | }) 960 | 961 | it('displays the clear icon when showClear is true', () => { 962 | const { queryByPlaceholderText, container } = render( 963 | {...defaultProps} showClear={true} /> 964 | ) 965 | const inputElement = queryByPlaceholderText(/search/i) 966 | 967 | fireEvent.change(inputElement!, { target: { value: 'something' } }) 968 | 969 | proceed() 970 | 971 | const clearIcon = container.querySelector('.clear-icon') 972 | expect(clearIcon).toBeInTheDocument() 973 | }) 974 | 975 | it('hides the clear icon when showClear is false', () => { 976 | const { queryByPlaceholderText, container } = render( 977 | {...defaultProps} showClear={false} /> 978 | ) 979 | const inputElement = queryByPlaceholderText(/search/i) 980 | 981 | fireEvent.change(inputElement!, { target: { value: 'something' } }) 982 | proceed() 983 | 984 | const clearIcon = container.querySelector('.clear-icon') 985 | expect(clearIcon).not.toBeInTheDocument() 986 | }) 987 | 988 | it('clears the text, sets focus and calls onClear when the clear icon is clicked', () => { 989 | const onClear = jest.fn() 990 | const onFocus = jest.fn() 991 | 992 | const { queryByPlaceholderText, container } = render( 993 | {...defaultProps} onClear={onClear} onFocus={onFocus} /> 994 | ) 995 | const inputElement = queryByPlaceholderText(/search/i) as HTMLInputElement 996 | 997 | fireEvent.change(inputElement!, { target: { value: 'something' } }) 998 | proceed() 999 | 1000 | expect(inputElement!.value).toBe('something') 1001 | 1002 | const clearIcon = container.querySelector('.clear-icon') 1003 | fireEvent.click(clearIcon!) 1004 | 1005 | expect(inputElement!.value).toBe('') 1006 | expect(inputElement!).toHaveFocus() 1007 | // Since the focus is set programmatically, skip the callback call 1008 | expect(onFocus).not.toHaveBeenCalled() 1009 | expect(onClear).toHaveBeenCalled() 1010 | }) 1011 | }) 1012 | 1013 | it('limits number of inputtable characters', () => { 1014 | const onSearch = jest.fn() 1015 | const { queryByPlaceholderText } = render( 1016 | {...defaultProps} maxLength={2} onSearch={onSearch} /> 1017 | ) 1018 | const inputElement = queryByPlaceholderText(/search/i) as HTMLInputElement 1019 | 1020 | fireEvent.change(inputElement, { target: { value: '1' } }) 1021 | proceed() 1022 | expect(onSearch).toHaveBeenCalledTimes(1) 1023 | 1024 | fireEvent.change(inputElement, { target: { value: '12' } }) 1025 | proceed() 1026 | expect(onSearch).toHaveBeenCalledTimes(2) 1027 | 1028 | fireEvent.change(inputElement, { target: { value: '123' } }) 1029 | proceed() 1030 | expect(onSearch).not.toHaveBeenCalledTimes(2) 1031 | 1032 | expect(inputElement).toHaveAttribute('maxLength', '2') 1033 | 1034 | // maxLenght is ignored by fireEvent.change therefore cannot test input value length 1035 | // https://github.com/testing-library/user-event/issues/591 1036 | }) 1037 | }) 1038 | -------------------------------------------------------------------------------- /test/Results.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '@testing-library/jest-dom/extend-expect' 3 | import { cleanup, fireEvent, render } from '@testing-library/react' 4 | import Results, { ResultsProps } from '../src/components/Results' 5 | 6 | type Item = { 7 | id: number 8 | name: string 9 | } 10 | 11 | const results: Item[] = [ 12 | { 13 | id: 0, 14 | name: 'value0' 15 | }, 16 | { 17 | id: 1, 18 | name: 'value1' 19 | }, 20 | { 21 | id: 2, 22 | name: 'value2' 23 | }, 24 | { 25 | id: 3, 26 | name: 'value3' 27 | } 28 | ] 29 | 30 | const defaultProps: ResultsProps = { 31 | results, 32 | onClick: () => {}, 33 | highlightedItem: 0, 34 | setHighlightedItem: () => {}, 35 | showIcon: true, 36 | maxResults: 10, 37 | setSearchString: () => {}, 38 | formatResult: undefined, 39 | resultStringKeyName: 'name', 40 | showNoResultsFlag: false 41 | } 42 | 43 | afterEach(cleanup) 44 | 45 | describe('', () => { 46 | it('renders results', () => { 47 | const { container } = render() 48 | expect(container.getElementsByClassName('line').length).toBe(1) 49 | expect(container.getElementsByTagName('li').length).toBe(4) 50 | expect(container.getElementsByClassName('ellipsis').length).toBe(4) 51 | expect(container.querySelectorAll('.search-icon').length).toBe(4) 52 | }) 53 | 54 | it('shows no results', () => { 55 | const { container } = render() 56 | expect(container.querySelector('.line')).toBe(null) 57 | }) 58 | 59 | it('calls onClick when result is clicked', () => { 60 | const onClick = jest.fn() 61 | const { container } = render() 62 | const liTag = container.getElementsByTagName('li')[0] 63 | 64 | fireEvent.click(liTag) 65 | 66 | expect(onClick).toHaveBeenCalled() 67 | }) 68 | 69 | it('hides the icon if showIcon is false', () => { 70 | const { container } = render() 71 | expect(container.querySelector('.icon')).toBe(null) 72 | }) 73 | 74 | it('renders only 2 result', () => { 75 | const { container } = render() 76 | expect(container.getElementsByClassName('line').length).toBe(1) 77 | expect(container.getElementsByClassName('ellipsis').length).toBe(2) 78 | expect(container.querySelectorAll('.search-icon').length).toBe(2) 79 | }) 80 | 81 | it('calls setHighlightedItem when result is hovered', () => { 82 | const setHighlightedItem = jest.fn() 83 | 84 | const { container } = render( 85 | 86 | ) 87 | 88 | const liTag = container.getElementsByTagName('li')[0] 89 | fireEvent.mouseEnter(liTag) 90 | 91 | expect(setHighlightedItem).toHaveBeenCalledWith({ index: 0 }) 92 | }) 93 | 94 | it('calls formatResult when renders results', () => { 95 | const formatResult = jest.fn() 96 | 97 | render() 98 | 99 | expect(formatResult).toHaveBeenCalledTimes(4) 100 | }) 101 | 102 | it('calls formatResult and render result appropriately', () => { 103 | const formatResult = (item: Item) => {item.name} 104 | 105 | const { container } = render() 106 | 107 | const items = container.getElementsByClassName('ellipsis') 108 | 109 | expect(items.length).toBe(4) 110 | 111 | expect(items[0].innerHTML).toMatch('value0') 112 | expect(items[1].innerHTML).toMatch('value1') 113 | expect(items[2].innerHTML).toMatch('value2') 114 | expect(items[3].innerHTML).toMatch('value3') 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /test/SearchInput.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect' 2 | import React from 'react' 3 | import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' 4 | import SearchInput from '../src/components/SearchInput' 5 | import 'regenerator-runtime/runtime' 6 | 7 | afterEach(cleanup) 8 | 9 | const defaultProps = { 10 | autoFocus: false, 11 | showIcon: true, 12 | showClear: true, 13 | placeholder: 'Search', 14 | setHighlightedItem: () => {}, 15 | setSearchString: () => {}, 16 | eraseResults: () => {}, 17 | searchString: '', 18 | onFocus: () => {}, 19 | onBlur: () => {}, 20 | onClear: () => {}, 21 | maxLength: 0 22 | } 23 | 24 | describe('', () => { 25 | jest.useFakeTimers() 26 | 27 | it('renders the input box', () => { 28 | const { queryByPlaceholderText } = render() 29 | const inputNode = queryByPlaceholderText(/search/i) 30 | 31 | expect(inputNode!).toBeInTheDocument() 32 | }) 33 | 34 | it('calls setSearchString on input change', () => { 35 | const setSearchString = jest.fn() 36 | const { queryByPlaceholderText } = render( 37 | 38 | ) 39 | const inputNode = queryByPlaceholderText(/search/i) 40 | 41 | fireEvent.change(inputNode!, { target: { value: 'Text' } }) 42 | 43 | expect(setSearchString).toHaveBeenCalled() 44 | }) 45 | 46 | it('calls onFocus when input is focused', () => { 47 | const onFocus = jest.fn() 48 | const { queryByPlaceholderText } = render() 49 | const inputNode = queryByPlaceholderText(/search/i) 50 | 51 | fireEvent.focus(inputNode!) 52 | 53 | expect(onFocus).toHaveBeenCalled() 54 | }) 55 | 56 | it('hides the icon when showIcon is false', () => { 57 | const { container } = render() 58 | 59 | expect(container.querySelectorAll('.search-icon').length).toBe(0) 60 | }) 61 | 62 | it('displays an icon when showIcon is true', () => { 63 | const { container } = render() 64 | 65 | expect(container.querySelectorAll('.search-icon').length).toBe(1) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src"], 3 | "exclude": [ 4 | "src/**/*.spec.ts", 5 | "src/**/*.test.ts" 6 | ], 7 | // "exclude": ["src/test"], 8 | "compilerOptions": { 9 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 10 | 11 | /* Basic Options */ 12 | // "incremental": true, /* Enable incremental compilation */ 13 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 14 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 15 | // "lib": [], /* Specify library files to be included in the compilation. */ 16 | "allowJs": true /* Allow javascript files to be compiled. */, 17 | // "checkJs": true, /* Report errors in .js files. */ 18 | "jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 19 | "declaration": true /* Generates corresponding '.d.ts' file. */, 20 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 21 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 22 | // "outFile": "./", /* Concatenate and emit output to single file. */ 23 | "outDir": "./dist/" /* Redirect output structure to the directory. */, 24 | "rootDirs": ["./src/"] /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 25 | // "composite": true, /* Enable project compilation */ 26 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 27 | // "removeComments": true, /* Do not emit comments to output. */ 28 | // "noEmit": true, /* Do not emit outputs. */ 29 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 30 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 31 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 32 | 33 | /* Strict Type-Checking Options */ 34 | "strict": true /* Enable all strict type-checking options. */, 35 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 36 | // "strictNullChecks": true, /* Enable strict null checks. */ 37 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 38 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 39 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 40 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 41 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 42 | 43 | /* Additional Checks */ 44 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 45 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 46 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 47 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 48 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 49 | 50 | /* Module Resolution Options */ 51 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 52 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 53 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 54 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 55 | // "typeRoots": [], /* List of folders to include type definitions from. */ 56 | // "types": [], /* Type declaration files to be included in compilation. */ 57 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 58 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 59 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 60 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 61 | 62 | /* Source Map Options */ 63 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 64 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 65 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 66 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 67 | 68 | /* Experimental Options */ 69 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 70 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 71 | 72 | /* Advanced Options */ 73 | "skipLibCheck": true /* Skip type checking of declaration files. */, 74 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 75 | } 76 | } 77 | --------------------------------------------------------------------------------