├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── demo └── demo.gif ├── docs ├── app.bundle.js ├── index.html ├── index.scss └── index.tsx ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── IHintOption.ts ├── __tests__ │ └── index.test.tsx ├── index.tsx └── utils.ts ├── tsconfig.json └── webpack.config.js /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | /dist 21 | /docs/** 22 | !docs/app.bundle.js 23 | !docs/index.html 24 | !docs/index.scss 25 | !docs/index.tsx 26 | 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - lts/* 4 | before_script: 5 | - npm install codecov -g 6 | script: 7 | - npm run coverage 8 | after_success: 9 | - codecov -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ejenavi Mudiaga 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-autocomplete-hint 2 | A React component for Autocomplete Hint. 3 | 4 | ![NPM](https://img.shields.io/npm/l/react-autocomplete-hint) 5 | ![npm](https://img.shields.io/npm/v/react-autocomplete-hint) 6 | [![Build Status](https://travis-ci.com/ejmudi/react-autocomplete-hint.svg?branch=master)](https://travis-ci.com/ejmudi/react-autocomplete-hint) 7 | [![codecov](https://codecov.io/gh/ejmudi/react-autocomplete-hint/graph/badge.svg)](https://codecov.io/gh/ejmudi/react-autocomplete-hint) 8 | 9 | ![](demo/demo.gif) 10 | 11 | 12 | ## Demo 13 | 14 | Demo can be found here: [https://ejmudi.github.io/react-autocomplete-hint/](https://ejmudi.github.io/react-autocomplete-hint/) 15 | 16 | 17 | ## Installation 18 | ``` 19 | npm install --save react-autocomplete-hint 20 | ``` 21 | or 22 | ``` 23 | yarn add react-autocomplete-hint 24 | ``` 25 | 26 | 27 | ## Usage 28 | ```jsx 29 | import { Hint } from 'react-autocomplete-hint'; 30 | 31 | const options = ["orange", "banana", "apple"]; 32 | 33 | // OR 34 | 35 | const options = [ 36 | {id: 1, label: "orange"}, 37 | {id: '2', label: "banana"}, 38 | {id: 3, label: "apple"} 39 | ]; 40 | 41 | 42 | setText(e.target.value)} /> 45 | 46 | 47 | ``` 48 | 49 | Click on the hint or use your keyboard **Right** key, **Tab** key (if `allowTabFill` is set to true), or **Enter** key (if `allowEnterFill` is set to true) to fill your input with the suggested hint. 50 | 51 | 52 | ## Props 53 | 54 | #### options (required): `Array | Array` 55 | 56 | #### disableHint (optional): `Boolean` 57 | 58 | #### allowTabFill (optional): `Boolean` 59 | 60 | #### allowEnterFill (optional): `Boolean` 61 | 62 | #### onFill (optional): `(value: string | object)=> void` 63 | 64 | #### onHint (optional): `(value: string | object | undefined)=> void` 65 | 66 | #### valueModifier (optional): `(value: string)=> string` 67 | 68 | 69 | ## object option 70 | If you're using objects for your options. object schema is as follows: 71 | 72 | #### id: `string | number` 73 | #### label: `string` 74 | 75 | 76 | ## onFill 77 | Returns the option selected immediately the input is filled with the suggested hint. 78 | 79 | Note that it won't return the selected option with the casing the user typed, rather it returns the option with the casing specified in your options prop. For example, if the options are specified like this:... 80 | 81 | ```jsx 82 | const options = ["orange", "banana", "apple"]; 83 | ``` 84 | ...and the input gets filled with *"ORange"*, onFill will still return *"orange"*. 85 | 86 | 87 | ## onHint 88 | Returns the current hint. 89 | 90 | 91 | ## valueModifier 92 | This prop accepts a function that modifies your input value before it is saved in state. 93 | 94 | It is typically useful when you are not setting `e.target.value` directly in state and need to modify the target value to 95 | some other value first before setting it in state. 96 | 97 | Example: A case where you need to set the input value to uppercase irrespective of the casing the user types in: 98 | 99 | ```jsx 100 | const options = ["orange", "banana", "apple"]; 101 | 102 | const modifyValue = (value: string) => value.toUpperCase(); 103 | 104 | 105 | setText(modifyValue(e.target.value))} /> 108 | 109 | ``` 110 | Note: Not setting the `valueModifier` prop in cases like this might result to a malformed hint. 111 | 112 | 113 | ## Duplicate data 114 | If you are using objects for your options, You may have unexpected results if your data options contain objects with duplicate labels. For this reason, it is highly recommended that you pass in objects with unique labels if possible. 115 | 116 | For example, if you pass in `optionsWithDuplicateLabels` as seen below and you then fill the input with the *orange* hint, the orange will be the first orange object found in the array as can be seen in the `onFill` prop: 117 | 118 | ```jsx 119 | const optionsWithDuplicateLabels = [ 120 | {id: "1", label: "orange"}, 121 | {id: "2", label: "orange"}, 122 | {id: "3", label: "banana"} 123 | ]; 124 | 125 | { 126 | // will always log {id: "1", label: "orange"} when orange is selected 127 | // {id: "2", label: "orange"} will never be logged. 128 | console.log(value); 129 | }}> 130 | setText(e.target.value)} /> 133 | 134 | 135 | ``` 136 | 137 | 138 | ## License 139 | [MIT](LICENSE) 140 | 141 | Inspired by [React Bootstrap Typeahead](https://github.com/ericgio/react-bootstrap-typeahead). 142 | -------------------------------------------------------------------------------- /demo/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ejmudi/react-autocomplete-hint/2e6ad8ba8d6003223943d8ec39ed016fd3d3daaa/demo/demo.gif -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Autocomplete Hint Demo 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /docs/index.scss: -------------------------------------------------------------------------------- 1 | .demo { 2 | padding: 1em; 3 | 4 | code { 5 | background: #eee; 6 | padding: 1em; 7 | display: inline-block; 8 | } 9 | 10 | .input-wrapper { 11 | padding: 1em; 12 | width: 234px; 13 | padding-left: 5px; 14 | 15 | .input-with-hint { 16 | border: 0.9px solid #888; 17 | padding: 0.5em 1em; 18 | border-radius: 0.2em; 19 | font-size: 1em; 20 | color: #333; 21 | width: 100%; 22 | 23 | &:hover { 24 | border-color: #66afe9; 25 | } 26 | 27 | &:focus { 28 | border-color: #66afe9; 29 | outline: 0; 30 | -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6); 31 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6); 32 | } 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /docs/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Hint } from '../src'; 4 | import './index.scss'; 5 | 6 | const Demo: React.FC = () => { 7 | const [text, setText] = useState(''); 8 | const options = ['Papaya', 'Persimmon', 'Pear', 'Peach', 'Apples', 'Apricots', 'Avocados']; 9 | 10 | return ( 11 |
12 |

13 | Try typing any of the words in the list below: 14 |

15 | 16 | ["Papaya", "Persimmon", "Pear", "Peach", "Apples", "Apricots", "Avocados"] 17 | 18 |
19 | 20 | setText(e.target.value)} /> 24 | 25 |
26 |

27 | Github Repo: https://github.com/ejmudi/react-autocomplete-hint 28 |

29 |
30 | ); 31 | } 32 | 33 | ReactDOM.render( 34 | , 35 | document.getElementById("root") 36 | ); -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | roots: [ 4 | '/src' 5 | ] 6 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-autocomplete-hint", 3 | "version": "2.0.0", 4 | "description": "A React component for Autocomplete hint", 5 | "main": "dist/src/index.js", 6 | "types": "dist/src/index.d.ts", 7 | "files": [ 8 | "dist/src/index.js", 9 | "dist/src/index.d.ts", 10 | "dist/src/utils.js", 11 | "dist/src/IHintOption.d.ts" 12 | ], 13 | "scripts": { 14 | "build": "webpack", 15 | "start:dev": "webpack-dev-server --open", 16 | "start": "npm run build && npm run start:dev", 17 | "jest": "./node_modules/jest/bin/jest.js", 18 | "test": "jest --watch", 19 | "coverage": "jest --coverage", 20 | "prepublishOnly": "tsc" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/ejmudi/react-autocomplete-hint.git" 25 | }, 26 | "author": "Ejenavi Mudiaga", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/ejmudi/react-autocomplete-hint/issues" 30 | }, 31 | "homepage": "https://github.com/ejmudi/react-autocomplete-hint#readme", 32 | "devDependencies": { 33 | "@testing-library/dom": "^8.13.0", 34 | "@testing-library/jest-dom": "^5.16.4", 35 | "@testing-library/react": "^11.2.7", 36 | "@testing-library/react-hooks": "^3.7.0", 37 | "@testing-library/user-event": "^12.8.3", 38 | "@types/jest": "^27.4.1", 39 | "@types/react": "^16.9.51", 40 | "@types/react-dom": "^16.9.8", 41 | "css-loader": "^3.6.0", 42 | "jest": "^27.5.1", 43 | "react": "^16.13.1", 44 | "react-dom": "^16.13.1", 45 | "react-test-renderer": "^17.0.1", 46 | "sass": "^1.27.0", 47 | "sass-loader": "^9.0.3", 48 | "style-loader": "^1.3.0", 49 | "ts-jest": "^27.1.4", 50 | "ts-loader": "^9.2.8", 51 | "typescript": "^3.9.7", 52 | "webpack": "^5.72.0", 53 | "webpack-cli": "^4.9.2", 54 | "webpack-dev-server": "^4.8.1" 55 | }, 56 | "peerDependencies": { 57 | "react": ">=16.0.0" 58 | }, 59 | "keywords": [ 60 | "autocomplete", 61 | "typeahead", 62 | "lookahead", 63 | "auto complete", 64 | "auto suggest", 65 | "auto-complete", 66 | "auto-suggest", 67 | "autocomplete", 68 | "autosuggest", 69 | "react", 70 | "react autocomplete", 71 | "react autosuggest", 72 | "react typeahead", 73 | "react-typeahead", 74 | "react-autocomplete", 75 | "react-autosuggest", 76 | "react hint", 77 | "react-hint", 78 | "input hint", 79 | "react input hint", 80 | "react-input-hint", 81 | "react-input-typeahead", 82 | "react-typeahead", 83 | "react-lookahead" 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /src/IHintOption.ts: -------------------------------------------------------------------------------- 1 | export interface IHintOption { 2 | id: string | number; 3 | label: string; 4 | } -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import React, { useRef } from 'react'; 6 | import { render, fireEvent } from '@testing-library/react'; 7 | import userEvent from '@testing-library/user-event'; 8 | import { Hint } from '..'; 9 | import { IHintOption } from '../IHintOption'; 10 | import { renderHook } from '@testing-library/react-hooks'; 11 | import '@testing-library/jest-dom/extend-expect'; 12 | 13 | const stringOptions = ['Persimmon', 'Pears', 'Pea', 'Papaya', 'Apples', 'Apricots', 'Avocados']; 14 | const objectOptions = [ 15 | { 16 | id: '1', 17 | label: 'Persimmon' 18 | }, 19 | { 20 | id: '2', 21 | label: 'Pears' 22 | }, 23 | { 24 | id: '3', 25 | label: 'Pea' 26 | }, 27 | { 28 | id: '4', 29 | label: 'Papaya' 30 | }, 31 | { 32 | id: '5', 33 | label: 'Apples' 34 | }, 35 | { 36 | id: '6', 37 | label: 'Apricots' 38 | }, 39 | { 40 | id: '7', 41 | label: 'Avocados' 42 | } 43 | ]; 44 | const ARROWRIGHT = 'ArrowRight'; 45 | const TAB = 'Tab'; 46 | const ENTER = 'Enter'; 47 | let input: HTMLInputElement; 48 | let hint: HTMLInputElement; 49 | let textFiller: HTMLSpanElement; 50 | 51 | describe('Hint input without allowTabFill and allowEnterFill props', () => { 52 | describe('With string options', () => { 53 | beforeEach(() => { 54 | const { container } = render( 55 | 56 | 57 | 58 | ); 59 | 60 | [input, hint, textFiller] = getElements(container); 61 | }); 62 | 63 | runCommonTests(); 64 | 65 | it(`should throw an error if Hint child is not of type 'input'`, () => { 66 | jest.spyOn(console, 'error').mockImplementation(() => {}); 67 | 68 | expect(() => { 69 | render( 70 | 71 |
72 | 73 | ) 74 | }).toThrowError(`react-autocomplete-hint: 'Hint' only accepts an 'input' element as child.`); 75 | }); 76 | 77 | it('should not have autocomplete functionality when disableHint is set to true', () => { 78 | const { container } = render( 79 | 80 | 81 | 82 | ); 83 | 84 | const inputs = container.getElementsByTagName('input'); 85 | expect(inputs.length).toBe(1); 86 | }); 87 | 88 | it('should call onFill callback once input gets filled with hint', () => { 89 | const handleOnFill = jest.fn(); 90 | 91 | const { container } = render( 92 | 93 | 94 | 95 | ); 96 | const [input, hint] = getElements(container); 97 | 98 | fireEvent.change(input, { target: { value: 'Pe' } }); 99 | fireEvent.keyDown(input, { key: ARROWRIGHT }); 100 | expect(handleOnFill).toHaveBeenCalledWith('Pea'); 101 | 102 | input.focus(); 103 | 104 | fireEvent.change(input, { target: { value: 'Pea' } }); 105 | fireEvent.click(hint, { target: { selectionEnd: 1 } }); 106 | expect(handleOnFill).toHaveBeenCalledWith('Pears'); 107 | 108 | input.focus(); 109 | 110 | fireEvent.change(input, { target: { value: 'Per' } }); 111 | fireEvent.keyDown(input, { key: TAB }); 112 | expect(handleOnFill).toHaveBeenCalledTimes(2); 113 | }); 114 | 115 | it('should not clash with other hint input instances', () => { 116 | const { container } = render( 117 | <> 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | ); 126 | 127 | runMultipleInstancesTest(container); 128 | }); 129 | 130 | it(`should fill input with correct text (with preserved casing) for contiguous hint click`, () => { 131 | const options = ['banana', 'banana!123']; 132 | runContiguousHintClickTest(options); 133 | }); 134 | }); 135 | 136 | describe('With object options', () => { 137 | beforeEach(() => { 138 | const { container } = render( 139 | 140 | 141 | 142 | ); 143 | 144 | [input, hint, textFiller] = getElements(container); 145 | }); 146 | 147 | runCommonTests(); 148 | 149 | it('should console warn user only once when two options have the same text', () => { 150 | const objectOptions = [ 151 | { 152 | id: 1, 153 | label: 'pea' 154 | }, 155 | { 156 | id: 2, 157 | label: 'papaya' 158 | }, 159 | { 160 | id: 3, 161 | label: 'pea' 162 | }, 163 | { 164 | id: 4, 165 | label: 'pear' 166 | } 167 | ]; 168 | 169 | console.warn = jest.fn(); 170 | 171 | const { container } = render( 172 | 173 | 174 | 175 | ); 176 | 177 | [input] = getElements(container); 178 | 179 | const warningMessage = `react-autocomplete-hint: "pea" occurs more than once and may cause errors. Options should not contain duplicate values!`; 180 | expect(console.warn).toHaveBeenCalledWith(warningMessage); 181 | 182 | fireEvent.change(input, { target: { value: 'pe' } }); 183 | fireEvent.keyDown(input, { key: ARROWRIGHT }); 184 | 185 | expect(console.warn).toHaveBeenCalledTimes(1); 186 | }); 187 | 188 | it('should call the onFill handler when input gets filled with hint', () => { 189 | const handleOnFill = jest.fn(); 190 | 191 | const { container } = render( 192 | 193 | 194 | 195 | ); 196 | const [input, hint] = getElements(container); 197 | 198 | fireEvent.change(input, { target: { value: 'Pe' } }); 199 | fireEvent.keyDown(input, { key: ARROWRIGHT }); 200 | expect(handleOnFill).toHaveBeenCalledWith({ 201 | id: '3', 202 | label: 'Pea' 203 | }); 204 | 205 | input.focus(); 206 | 207 | fireEvent.change(input, { target: { value: 'Pea' } }); 208 | fireEvent.click(hint, { target: { selectionEnd: 1 } }); 209 | expect(handleOnFill).toHaveBeenCalledWith({ 210 | id: '2', 211 | label: 'Pears' 212 | }); 213 | 214 | input.focus(); 215 | 216 | fireEvent.change(input, { target: { value: 'Per' } }); 217 | fireEvent.keyDown(input, { key: TAB }); 218 | expect(handleOnFill).toHaveBeenCalledTimes(2); 219 | }); 220 | 221 | it('should call the onFill handler with the correct data when there are two options with same id', () => { 222 | const handleOnFill = jest.fn(); 223 | 224 | const objectOptions = [ 225 | { id: 1, label: 'Persimmon' }, 226 | { id: 1, label: 'Pea' }, 227 | { id: 2, label: 'Pear' }, 228 | ]; 229 | 230 | const { container } = render( 231 | 232 | 233 | 234 | ); 235 | const [input] = getElements(container); 236 | 237 | fireEvent.change(input, { target: { value: 'Pe' } }); 238 | fireEvent.keyDown(input, { key: ARROWRIGHT }); 239 | expect(handleOnFill).toHaveBeenCalledWith({ 240 | id: 1, 241 | label: 'Pea' 242 | }); 243 | }); 244 | 245 | it('should not clash with other hint input instances', () => { 246 | const { container } = render( 247 | <> 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | ); 256 | 257 | runMultipleInstancesTest(container); 258 | }); 259 | 260 | it(`should fill input with correct text (with Preserved Casing) for contiguous hint click`, () => { 261 | const options = [ 262 | { 263 | id: '1', 264 | label: 'banana' 265 | }, 266 | { 267 | id: '2', 268 | label: 'banana!123' 269 | } 270 | ]; 271 | runContiguousHintClickTest(options); 272 | }); 273 | }); 274 | 275 | function getElements(container: Element): [HTMLInputElement, HTMLInputElement, HTMLSpanElement] { 276 | const inputs = container.getElementsByTagName('input'); 277 | const input = inputs[0]; 278 | const hint = inputs[1]; 279 | const textFiller = container.getElementsByClassName('rah-text-filler')[0] as HTMLSpanElement; 280 | 281 | return [input, hint, textFiller]; 282 | } 283 | 284 | function runCommonTests() { 285 | it('should suggest the correct hint while typing', () => { 286 | fireEvent.change(input, { target: { value: 'P' } }); 287 | expect(hint.value).toBe('apaya'); 288 | 289 | fireEvent.change(input, { target: { value: 'Pe' } }); 290 | expect(hint.value).toBe('a'); 291 | 292 | fireEvent.change(input, { target: { value: '' } }); 293 | expect(hint.value).toBe(''); 294 | }); 295 | 296 | it('should fill up the text filler behind correctly while typing', () => { 297 | fireEvent.change(input, { target: { value: 'P' } }); 298 | expect(textFiller.innerHTML).toBe('P'); 299 | 300 | fireEvent.change(input, { target: { value: 'Pe' } }); 301 | expect(textFiller.innerHTML).toBe('Pe'); 302 | }); 303 | 304 | it('should fill the input correctly on press of right button', () => { 305 | fireEvent.change(input, { target: { value: 'ap' } }); 306 | fireEvent.keyDown(input, { key: ARROWRIGHT }); 307 | 308 | expect(input.value).toBe('apples'); 309 | expect(hint.value).toBe(''); 310 | expect(textFiller.innerHTML).toBe(''); 311 | }); 312 | 313 | it('should remove hint on blur of the input and re-add it on refocus', () => { 314 | input.focus(); 315 | 316 | fireEvent.change(input, { target: { value: 'ap' } }); 317 | expect(hint.value).toBe('ples'); 318 | 319 | input.blur(); 320 | expect(hint.value).toBe(''); 321 | 322 | input.focus(); 323 | expect(hint.value).toBe('ples'); 324 | }); 325 | 326 | it('should have the correct values when the filled text is a part of the next match and goes through a peculiar blur-focus scenario', () => { 327 | input.focus(); 328 | 329 | fireEvent.change(input, { target: { value: 'pe' } }); 330 | expect(hint.value).toBe('a'); 331 | 332 | fireEvent.keyDown(input, { key: ARROWRIGHT }); 333 | expect(input.value).toBe('pea'); 334 | expect(hint.value).toBe(''); 335 | expect(textFiller.innerHTML).toBe(''); 336 | 337 | input.blur(); 338 | input.focus(); 339 | 340 | expect(input.value).toBe('pea'); 341 | expect(hint.value).toBe('rs'); 342 | expect(textFiller.innerHTML).toBe('pea'); 343 | }); 344 | 345 | it('should not allow Tab button to fill input with hint', () => { 346 | fireEvent.change(input, { target: { value: 'ap' } }); 347 | fireEvent.keyDown(input, { key: TAB }); 348 | 349 | expect(input.value).toBe('ap'); 350 | expect(hint.value).toBe('ples'); 351 | }); 352 | 353 | it('should not fill input with hint when caret is not at the end of text', () => { 354 | fireEvent.change(input, { target: { value: 'Pe', selectionEnd: 1 } }); 355 | fireEvent.keyDown(input, { key: ARROWRIGHT }); 356 | 357 | expect(input.value).toBe('Pe'); 358 | }); 359 | 360 | it(`should not suggest hint when there's no match`, () => { 361 | fireEvent.change(input, { target: { value: 'Pears' } }); 362 | expect(hint.value).toBe(''); 363 | 364 | fireEvent.keyDown(input, { key: ARROWRIGHT }); 365 | expect(hint.value).toBe(''); 366 | }); 367 | } 368 | 369 | function runMultipleInstancesTest(container: Element) { 370 | const inputs = container.getElementsByTagName('input'); 371 | const input1 = inputs[0], 372 | input2 = inputs[2], 373 | hint2 = inputs[3]; 374 | 375 | fireEvent.change(input1, { target: { value: 'Pe' } }); 376 | expect(hint2.value).toBe(''); 377 | 378 | fireEvent.keyDown(input1, { key: ARROWRIGHT }); 379 | expect(input2.value).toBe(''); 380 | expect(hint2.value).toBe(''); 381 | } 382 | 383 | function runContiguousHintClickTest(options: Array | Array) { 384 | const { container } = render( 385 | 386 | 387 | 388 | ); 389 | [input, hint, textFiller] = getElements(container); 390 | 391 | fireEvent.change(input, { target: { value: 'bA' } }); 392 | fireEvent.click(hint, { target: { selectionEnd: 1 } }); 393 | 394 | expect(input.value).toBe('bAnana'); 395 | fireEvent.click(hint, { target: { selectionEnd: 1 } }); 396 | expect(hint.value).toBe('!123'); 397 | 398 | fireEvent.click(hint, { target: { selectionEnd: 1 } }); 399 | expect(input.value).toBe('bAnana!123'); 400 | } 401 | }); 402 | 403 | describe('Hint input with allowEnterFill prop set to true', () => { 404 | describe('With string options', () => { 405 | beforeEach(() => { 406 | const { container } = render( 407 | 408 | 409 | 410 | ); 411 | 412 | [input, hint] = getElements(container); 413 | }); 414 | 415 | runCommonTests(); 416 | }); 417 | 418 | describe('With object options', () => { 419 | beforeEach(() => { 420 | const { container } = render( 421 | 422 | 423 | 424 | ); 425 | 426 | [input, hint] = getElements(container); 427 | }); 428 | 429 | runCommonTests(); 430 | 431 | it('should call onFill callback once input gets filled with hint', () => { 432 | const handleOnFill = jest.fn(); 433 | 434 | const { container } = render( 435 | 436 | 437 | 438 | ); 439 | const [input] = getElements(container); 440 | 441 | fireEvent.change(input, { target: { value: 'Pe' } }); 442 | fireEvent.keyDown(input, { key: ENTER }); 443 | expect(handleOnFill).toHaveBeenCalledWith({ 444 | id: '3', 445 | label: 'Pea' 446 | }); 447 | }); 448 | }); 449 | 450 | function getElements(container: Element): [HTMLInputElement, HTMLInputElement] { 451 | const inputs = container.getElementsByTagName('input'); 452 | const input = inputs[0]; 453 | const hint = inputs[1]; 454 | 455 | return [input, hint]; 456 | } 457 | 458 | function runCommonTests() { 459 | it('should fill the input correctly on press of Enter button', async () => { 460 | fireEvent.change(input, { target: { value: 'ap' } }); 461 | fireEvent.keyDown(input, { key: ENTER }); 462 | 463 | expect(input.value).toBe('apples'); 464 | expect(hint.value).toBe(''); 465 | }); 466 | 467 | it('should not mess up the default behaviour, should fill the input correctly on press of right button', async () => { 468 | fireEvent.change(input, { target: { value: 'ap' } }); 469 | fireEvent.keyDown(input, { key: ARROWRIGHT }); 470 | 471 | expect(input.value).toBe('apples'); 472 | expect(hint.value).toBe(''); 473 | }); 474 | 475 | it('should not fill input with hint when caret is not at the end of the input text', () => { 476 | fireEvent.change(input, { target: { value: 'Pe', selectionEnd: 1 } }); 477 | fireEvent.keyDown(input, { key: ENTER }); 478 | fireEvent.keyDown(input, { key: ARROWRIGHT }); 479 | 480 | expect(input.value).toBe('Pe'); 481 | }); 482 | 483 | } 484 | }); 485 | 486 | describe('Hint input with allowTabFill prop set to true', () => { 487 | describe('With string options', () => { 488 | beforeEach(() => { 489 | const { container } = render( 490 | 491 | 492 | 493 | ); 494 | 495 | [input, hint] = getElements(container); 496 | }); 497 | 498 | runCommonTests(); 499 | }); 500 | 501 | describe('With object options', () => { 502 | beforeEach(() => { 503 | const { container } = render( 504 | 505 | 506 | 507 | ); 508 | 509 | [input, hint] = getElements(container); 510 | }); 511 | 512 | runCommonTests(); 513 | 514 | it('should call onFill callback once input gets filled with hint', () => { 515 | const handleOnFill = jest.fn(); 516 | 517 | const { container } = render( 518 | 519 | 520 | 521 | ); 522 | const [input] = getElements(container); 523 | 524 | fireEvent.change(input, { target: { value: 'Pe' } }); 525 | fireEvent.keyDown(input, { key: TAB }); 526 | expect(handleOnFill).toHaveBeenCalledWith({ 527 | id: '3', 528 | label: 'Pea' 529 | }); 530 | }); 531 | }); 532 | 533 | function getElements(container: Element): [HTMLInputElement, HTMLInputElement] { 534 | const inputs = container.getElementsByTagName('input'); 535 | const input = inputs[0]; 536 | const hint = inputs[1]; 537 | 538 | return [input, hint]; 539 | } 540 | 541 | function runCommonTests() { 542 | it('should fill the input correctly on press of tab button', async () => { 543 | fireEvent.change(input, { target: { value: 'ap' } }); 544 | fireEvent.keyDown(input, { key: TAB }); 545 | 546 | expect(input.value).toBe('apples'); 547 | expect(hint.value).toBe(''); 548 | }); 549 | 550 | it('should fill the input correctly on press of right button', async () => { 551 | fireEvent.change(input, { target: { value: 'ap' } }); 552 | fireEvent.keyDown(input, { key: ARROWRIGHT }); 553 | 554 | expect(input.value).toBe('apples'); 555 | expect(hint.value).toBe(''); 556 | }); 557 | 558 | it('should not fill input with hint when caret is not at the end of the input text', () => { 559 | fireEvent.change(input, { target: { value: 'Pe', selectionEnd: 1 } }); 560 | fireEvent.keyDown(input, { key: TAB }); 561 | fireEvent.keyDown(input, { key: ARROWRIGHT }); 562 | 563 | expect(input.value).toBe('Pe'); 564 | }); 565 | 566 | } 567 | }); 568 | 569 | describe('Hint input with allowTabFill prop set to true alongside another input', () => { 570 | let nextInput: HTMLInputElement; 571 | 572 | describe('With string options', () => { 573 | beforeEach(() => { 574 | const { container } = render( 575 | <> 576 | 577 | 578 | 579 | 580 | 581 | ); 582 | 583 | [input, nextInput] = getElements(container); 584 | }); 585 | 586 | runCommonTests(); 587 | }); 588 | 589 | describe('With object options', () => { 590 | beforeEach(() => { 591 | const { container } = render( 592 | <> 593 | 594 | 595 | 596 | 597 | 598 | ); 599 | 600 | [input, nextInput] = getElements(container); 601 | }); 602 | 603 | runCommonTests(); 604 | }); 605 | 606 | function getElements(container: Element): [HTMLInputElement, HTMLInputElement] { 607 | const inputs = container.getElementsByTagName('input'); 608 | const input = inputs[0]; 609 | const nextInput = inputs[2]; 610 | 611 | return [input, nextInput]; 612 | } 613 | 614 | function runCommonTests() { 615 | it(`should not move focus to the next-input when there's a hint to fill and Tab button is pressed`, () => { 616 | input.focus(); 617 | fireEvent.change(input, { target: { value: 'Pe' } }); 618 | userEvent.tab(); 619 | 620 | expect(input).toHaveFocus(); 621 | }); 622 | 623 | it('should move focus to the next-input when the caret is not at the end of the main-input text and Tab button is pressed', () => { 624 | input.focus(); 625 | fireEvent.change(input, { target: { value: 'Pea', selectionEnd: 1 } }); 626 | userEvent.tab(); 627 | 628 | expect(nextInput).toHaveFocus(); 629 | }); 630 | 631 | it('should move focus to the next-input when the main-input is empty and Tab button is pressed', () => { 632 | input.focus(); 633 | fireEvent.change(input, { target: { value: '', } }); 634 | userEvent.tab(); 635 | 636 | expect(nextInput).toHaveFocus(); 637 | }); 638 | 639 | it(`should go to the next tabIndex when there's no hint suggestion and Tab button is pressed`, () => { 640 | input.focus(); 641 | fireEvent.change(input, { target: { value: 'RandomText' } }); 642 | userEvent.tab(); 643 | 644 | expect(nextInput).toHaveFocus(); 645 | }); 646 | 647 | it(`should go to the next tabIndex when one of the options is typed and there's no hint suggestion and Tab button is pressed`, () => { 648 | input.focus(); 649 | fireEvent.change(input, { target: { value: 'Pears' } }); 650 | userEvent.tab(); 651 | 652 | expect(nextInput).toHaveFocus(); 653 | }); 654 | } 655 | }); 656 | 657 | describe('Hint input with onClick hint fill feature', () => { 658 | describe('With string options', () => { 659 | beforeEach(() => { 660 | const { container } = render( 661 | 662 | 663 | 664 | ); 665 | 666 | [input, hint, textFiller] = getElements(container); 667 | }); 668 | 669 | runCommonTests(); 670 | }); 671 | 672 | describe('With object options', () => { 673 | beforeEach(() => { 674 | const { container } = render( 675 | 676 | 677 | 678 | ); 679 | 680 | [input, hint, textFiller] = getElements(container); 681 | }); 682 | 683 | runCommonTests(); 684 | }); 685 | 686 | function getElements(container: Element): [HTMLInputElement, HTMLInputElement, HTMLSpanElement] { 687 | const inputs = container.getElementsByTagName('input'); 688 | const input = inputs[0]; 689 | const hint = inputs[1]; 690 | const textFiller = container.getElementsByClassName('rah-text-filler')[0] as HTMLSpanElement; 691 | 692 | return [input, hint, textFiller]; 693 | } 694 | 695 | function runCommonTests() { 696 | it('should not fill the input when the user clicks the hint at hint char position 0', () => { 697 | fireEvent.change(input, { target: { value: 'Pers' } }); 698 | fireEvent.click(hint, { target: { selectionEnd: 0 } }); 699 | expect(input.value).toBe('Pers'); 700 | expect(input.selectionEnd).toBe(4); 701 | }); 702 | 703 | it('should fill the input when the user clicks the hint at a hint char position other than 0', () => { 704 | fireEvent.change(input, { target: { value: 'Pers' } }); 705 | fireEvent.click(hint, { target: { selectionEnd: 1 } }); 706 | 707 | expect(input.value).toBe('Persimmon'); 708 | expect(hint.value).toBe(''); 709 | expect(textFiller.innerHTML).toBe(''); 710 | }); 711 | 712 | it('should move focus to the mainInput with the caret at the end of the text when the user clicks the hint at position 0', () => { 713 | fireEvent.change(input, { target: { value: 'Pers' } }); 714 | fireEvent.click(hint, { target: { selectionEnd: 0 } }); 715 | 716 | expect(input).toHaveFocus(); 717 | expect(input.selectionEnd).toBe(4); 718 | }); 719 | 720 | it('should keep the mainInput caret at the position where the user clicked the hint, if clicked at a position other than 0', () => { 721 | jest.useFakeTimers(); 722 | 723 | fireEvent.change(input, { target: { value: 'Pers' } }); 724 | fireEvent.click(hint, { target: { selectionEnd: 1 } }); 725 | 726 | jest.runAllTimers(); 727 | 728 | expect(input.selectionEnd).toBe(5); 729 | }); 730 | } 731 | }); 732 | 733 | describe('Hint with ref set on input', () => { 734 | describe('With string options', () => { 735 | runNonCallbackRefsTest(stringOptions); 736 | runCallbackRefsTest(stringOptions); 737 | }); 738 | 739 | describe('With object options', () => { 740 | runNonCallbackRefsTest(objectOptions); 741 | runCallbackRefsTest(objectOptions); 742 | }); 743 | 744 | function runNonCallbackRefsTest(options: Array | Array) { 745 | it('should preserve a non-callback ref set on the input', () => { 746 | const { result } = renderHook(() => useRef(null)); 747 | const inputRef = result.current; 748 | 749 | const { container } = render( 750 | 751 | 752 | 753 | ); 754 | 755 | if (inputRef.current) { 756 | inputRef.current.style.lineHeight = '1.5px'; 757 | } 758 | 759 | runRefPreservationTest(container); 760 | }); 761 | } 762 | 763 | function runCallbackRefsTest(options: Array | Array) { 764 | it('should preserve callback ref set on the input', () => { 765 | let inputRef: any; 766 | 767 | const { container } = render( 768 | 769 | { 770 | inputRef = element; 771 | }} /> 772 | 773 | ); 774 | 775 | inputRef.style.lineHeight = '1.5px'; 776 | 777 | runRefPreservationTest(container); 778 | }); 779 | } 780 | 781 | function runRefPreservationTest(container: Element) { 782 | const inputs = container.getElementsByTagName('input'); 783 | const input = inputs[0]; 784 | const hint = inputs[1]; 785 | const hintWrapper = container.getElementsByClassName('rah-hint-wrapper')[0] as HTMLSpanElement; 786 | 787 | fireEvent.change(input, { target: { value: 'a' } }); 788 | 789 | expect(hintWrapper.style.lineHeight).toBe('1.5px'); 790 | expect(hint.style.lineHeight).toBe('1.5px'); 791 | } 792 | }); 793 | 794 | describe('Hint input with valueModifier prop set', () => { 795 | const modifyValue = (value: string) => { 796 | if (value[0] && value[0] === value[0].toLowerCase()) { 797 | return value.toUpperCase(); 798 | } 799 | return value.toLowerCase(); 800 | }; 801 | 802 | const onChangeHandler = jest.fn((e) => { 803 | e.target.value = modifyValue(e.target.value); 804 | }); 805 | 806 | describe('With string options', () => { 807 | beforeEach(() => { 808 | const { container } = render( 809 | 810 | 811 | 812 | ); 813 | 814 | [input, hint, textFiller] = getElements(container); 815 | }); 816 | 817 | runCommonTests(); 818 | }); 819 | 820 | describe('With object options', () => { 821 | beforeEach(() => { 822 | const { container } = render( 823 | 824 | 825 | 826 | ); 827 | 828 | [input, hint, textFiller] = getElements(container); 829 | }); 830 | 831 | runCommonTests(); 832 | }); 833 | 834 | function getElements(container: Element): [HTMLInputElement, HTMLInputElement, HTMLSpanElement] { 835 | const inputs = container.getElementsByTagName('input'); 836 | const input = inputs[0]; 837 | const hint = inputs[1]; 838 | const textFiller = container.getElementsByClassName('rah-text-filler')[0] as HTMLSpanElement; 839 | 840 | return [input, hint, textFiller]; 841 | } 842 | 843 | function runCommonTests() { 844 | it('should fill up the text filler behind correctly while typing', () => { 845 | fireEvent.change(input, { target: { value: 'per' } }); 846 | expect(textFiller.innerHTML).toBe('PER'); 847 | 848 | fireEvent.change(input, { target: { value: 'Per' } }); 849 | expect(textFiller.innerHTML).toBe('per'); 850 | }); 851 | 852 | it('should fill the input correctly on press of right button', () => { 853 | fireEvent.change(input, { target: { value: 'peR' } }); 854 | fireEvent.keyDown(input, { key: ARROWRIGHT }); 855 | expect(input.value).toBe('PERSIMMON'); 856 | }); 857 | } 858 | }); 859 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | cloneElement, 4 | useEffect, 5 | useRef, 6 | ReactElement 7 | } from 'react'; 8 | import { IHintOption } from './IHintOption'; 9 | import { 10 | mergeRefs, 11 | interpolateStyle, 12 | sortAsc, 13 | getFirstDuplicateOption 14 | } from './utils'; 15 | 16 | export interface IHintProps { 17 | options: Array | Array; 18 | disableHint?: boolean; 19 | children: ReactElement; 20 | allowTabFill?: boolean; 21 | allowEnterFill?: boolean; 22 | onFill?(value: string | IHintOption): void; 23 | onHint?(value: string | IHintOption | undefined): void; 24 | valueModifier?(value: string): string; 25 | } 26 | 27 | export const Hint: React.FC = props => { 28 | const child = React.Children.only(props.children); 29 | 30 | if (child.type?.toString()?.toLowerCase() !== 'input') { 31 | throw new TypeError(`react-autocomplete-hint: 'Hint' only accepts an 'input' element as child.`); 32 | } 33 | 34 | const { 35 | options, 36 | disableHint, 37 | allowTabFill, 38 | allowEnterFill, 39 | onFill, 40 | onHint, 41 | valueModifier 42 | } = props; 43 | 44 | const childProps = child.props; 45 | 46 | let mainInputRef = useRef(null); 47 | let hintWrapperRef = useRef(null); 48 | let hintRef = useRef(null); 49 | const [unModifiedInputText, setUnmodifiedInputText] = useState(''); 50 | const [text, setText] = useState(''); 51 | const [hint, setHint] = useState(''); 52 | const [match, setMatch] = useState(); 53 | const [changeEvent, setChangeEvent] = useState>(); 54 | 55 | useEffect(() => { 56 | if (typeof options[0] === 'object') { 57 | const duplicate = getFirstDuplicateOption(options as Array); 58 | if (duplicate) { 59 | console.warn(`react-autocomplete-hint: "${duplicate}" occurs more than once and may cause errors. Options should not contain duplicate values!`); 60 | } 61 | } 62 | }, [options]); 63 | 64 | useEffect(() => { 65 | if (disableHint) { 66 | return; 67 | } 68 | 69 | const inputStyle = mainInputRef.current && window.getComputedStyle(mainInputRef.current); 70 | inputStyle && styleHint(hintWrapperRef, hintRef, inputStyle); 71 | }); 72 | 73 | const getMatch = (text: string) => { 74 | if (!text || text === '') { 75 | return; 76 | } 77 | 78 | if (typeof (options[0]) === 'string') { 79 | const match = (options as Array) 80 | .filter(x => x.toLowerCase() !== text.toLowerCase() && x.toLowerCase().startsWith(text.toLowerCase())) 81 | .sort()[0]; 82 | 83 | return match; 84 | } else { 85 | const match = (options as Array) 86 | .filter(x => x.label.toLowerCase() !== text.toLowerCase() && x.label.toLowerCase().startsWith(text.toLowerCase())) 87 | .sort((a, b) => sortAsc(a.label, b.label))[0]; 88 | 89 | return match; 90 | } 91 | }; 92 | 93 | const setHintTextAndId = (text: string) => { 94 | setText(text); 95 | 96 | const match = getMatch(text); 97 | let hint: string; 98 | 99 | if (!match) { 100 | hint = ''; 101 | } 102 | else if (typeof match === 'string') { 103 | hint = match.slice(text.length); 104 | } else { 105 | hint = match.label.slice(text.length); 106 | } 107 | 108 | setHint(hint); 109 | setMatch(match); 110 | onHint && onHint(match) 111 | } 112 | 113 | const handleOnFill = () => { 114 | if (hint === '' || !changeEvent) { 115 | return; 116 | } 117 | 118 | const newUnModifiedText = unModifiedInputText + hint; 119 | 120 | changeEvent.target.value = newUnModifiedText; 121 | childProps.onChange && childProps.onChange(changeEvent); 122 | setHintTextAndId(''); 123 | 124 | onFill && onFill(match!); 125 | 126 | setUnmodifiedInputText(newUnModifiedText); 127 | }; 128 | 129 | const styleHint = ( 130 | hintWrapperRef: React.RefObject, 131 | hintRef: React.RefObject, 132 | inputStyle: CSSStyleDeclaration) => { 133 | if (hintWrapperRef?.current?.style) { 134 | hintWrapperRef.current.style.fontFamily = inputStyle.fontFamily; 135 | hintWrapperRef.current.style.fontSize = inputStyle.fontSize; 136 | hintWrapperRef.current.style.width = inputStyle.width; 137 | hintWrapperRef.current.style.height = inputStyle.height; 138 | hintWrapperRef.current.style.lineHeight = inputStyle.lineHeight; 139 | hintWrapperRef.current.style.boxSizing = inputStyle.boxSizing; 140 | hintWrapperRef.current.style.margin = interpolateStyle(inputStyle, 'margin'); 141 | hintWrapperRef.current.style.padding = interpolateStyle(inputStyle, 'padding'); 142 | hintWrapperRef.current.style.borderStyle = interpolateStyle(inputStyle, 'border', 'style'); 143 | hintWrapperRef.current.style.borderWidth = interpolateStyle(inputStyle, 'border', 'width'); 144 | } 145 | 146 | if (hintRef?.current?.style) { 147 | hintRef.current.style.fontFamily = inputStyle.fontFamily; 148 | hintRef.current.style.fontSize = inputStyle.fontSize; 149 | hintRef.current.style.lineHeight = inputStyle.lineHeight; 150 | } 151 | }; 152 | 153 | const onChange = (e: React.ChangeEvent) => { 154 | setChangeEvent(e); 155 | e.persist(); 156 | 157 | setUnmodifiedInputText(e.target.value); 158 | const modifiedValue = valueModifier ? valueModifier(e.target.value) : e.target.value; 159 | setHintTextAndId(modifiedValue); 160 | 161 | childProps.onChange && childProps.onChange(e); 162 | }; 163 | 164 | const onFocus = (e: React.FocusEvent) => { 165 | setHintTextAndId(e.target.value); 166 | childProps.onFocus && childProps.onFocus(e); 167 | }; 168 | 169 | const onBlur = (e: React.FocusEvent) => { 170 | //Only blur it if the new focus isn't the the hint input 171 | if (hintRef?.current !== e.relatedTarget) { 172 | setHintTextAndId(''); 173 | childProps.onBlur && childProps.onBlur(e); 174 | } 175 | }; 176 | 177 | const ARROWRIGHT = 'ArrowRight'; 178 | const TAB = 'Tab'; 179 | const ENTER = 'Enter'; 180 | const onKeyDown = (e: React.KeyboardEvent) => { 181 | const caretIsAtTextEnd = (() => { 182 | // For selectable input types ("text", "search"), only select the hint if 183 | // it's at the end of the input value. For non-selectable types ("email", 184 | // "number"), always select the hint. 185 | 186 | const isNonSelectableType = e.currentTarget.selectionEnd === null; 187 | const caretIsAtTextEnd = isNonSelectableType || e.currentTarget.selectionEnd === e.currentTarget.value.length; 188 | 189 | return caretIsAtTextEnd; 190 | })(); 191 | 192 | if (caretIsAtTextEnd && e.key === ARROWRIGHT) { 193 | handleOnFill(); 194 | } else if (caretIsAtTextEnd && allowTabFill && e.key === TAB && hint !== '') { 195 | e.preventDefault(); 196 | handleOnFill(); 197 | } else if (caretIsAtTextEnd && allowEnterFill && e.key === ENTER && hint !== '') { 198 | e.preventDefault(); 199 | handleOnFill(); 200 | } 201 | 202 | childProps.onKeyDown && childProps.onKeyDown(e); 203 | }; 204 | 205 | const onHintClick = (e: React.MouseEvent) => { 206 | const hintCaretPosition = e.currentTarget.selectionEnd || 0; 207 | 208 | // If user clicks the position before the first character of the hint, 209 | // move focus to the end of the mainInput text 210 | if (hintCaretPosition === 0) { 211 | mainInputRef.current?.focus(); 212 | return; 213 | } 214 | 215 | if (!!hint && hint !== '') { 216 | handleOnFill(); 217 | setTimeout(() => { 218 | mainInputRef.current?.focus(); 219 | const caretPosition = text.length + hintCaretPosition; 220 | mainInputRef.current?.setSelectionRange(caretPosition, caretPosition); 221 | }, 0); 222 | } 223 | }; 224 | 225 | const childRef = cloneElement(child as any).ref; 226 | const mainInput = cloneElement( 227 | child, 228 | { 229 | ...childProps, 230 | onChange, 231 | onBlur, 232 | onFocus, 233 | onKeyDown, 234 | ref: childRef && typeof (childRef) !== 'string' 235 | ? mergeRefs(childRef, mainInputRef) 236 | : mainInputRef 237 | } 238 | ); 239 | 240 | return ( 241 |
246 | { 247 | disableHint 248 | ? child 249 | : ( 250 | <> 251 | {mainInput} 252 | 267 | 275 | {text} 276 | 277 | 296 | 297 | 298 | ) 299 | } 300 |
301 | ); 302 | } -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, RefCallback } from "react"; 2 | import { IHintOption } from "./IHintOption"; 3 | 4 | type MutableRef = RefCallback | MutableRefObject | null; 5 | 6 | export function mergeRefs(...refs: Array>) { 7 | const filteredRefs = refs.filter(Boolean); 8 | 9 | return (inst: HTMLElement) => { 10 | for (let ref of filteredRefs) { 11 | if (typeof ref === 'function') { 12 | ref(inst); 13 | } else if (ref) { 14 | ref.current = inst; 15 | } 16 | } 17 | }; 18 | }; 19 | 20 | // IE doesn't seem to get the composite computed value (eg: 'padding', 21 | // 'borderStyle', etc.), so generate these from the individual values. 22 | export function interpolateStyle( 23 | styles: CSSStyleDeclaration, 24 | attr: string, 25 | subattr: string = '' 26 | ): string { 27 | // Title-case the sub-attribute. 28 | if (subattr) { 29 | subattr = subattr.replace(subattr[0], subattr[0].toUpperCase()); 30 | } 31 | 32 | return ['Top', 'Right', 'Bottom', 'Left'] 33 | // @ts-ignore: (attr + dir + subattr) property cannot be determined at compile time 34 | .map((dir) => styles[attr + dir + subattr]) 35 | .join(' '); 36 | } 37 | 38 | export function sortAsc(a: T, b: T) { 39 | if (a > b) { 40 | return 1; 41 | } 42 | if (a < b) { 43 | return -1; 44 | } 45 | return 0; 46 | } 47 | 48 | export function getFirstDuplicateOption(array: Array) { 49 | let tracker: { [key: string]: boolean } = {}; 50 | 51 | for (let i = 0; i < array.length; i++) { 52 | if (tracker[array[i].label]) { 53 | return array[i].label; 54 | } 55 | 56 | tracker[array[i].label] = true; 57 | } 58 | 59 | return null; 60 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "esModuleInterop": true, 5 | "jsx": "react", 6 | "declaration": true, 7 | "outDir": "dist", 8 | "strict": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | mode: "development", 5 | entry: { 6 | app: [ 7 | './docs/index.tsx' 8 | ] 9 | }, 10 | devServer: { 11 | static: { 12 | directory: path.join(__dirname, 'docs'), 13 | }, 14 | compress: true, 15 | port: 8080, 16 | hot: true 17 | }, 18 | output: { 19 | filename: '[name].bundle.js', 20 | path: path.resolve(__dirname, './docs') 21 | }, 22 | resolve: { 23 | extensions: [".ts", ".tsx", ".js"] 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.tsx?$/, 29 | exclude: /(node_modules)/, 30 | loader: "ts-loader" 31 | }, 32 | { 33 | "test": /\.scss$/, 34 | "use": [ 35 | "style-loader", 36 | "css-loader", 37 | "sass-loader" 38 | ] 39 | }, 40 | ] 41 | } 42 | }; --------------------------------------------------------------------------------