├── .gitignore ├── .npmignore ├── .storybook ├── config.js └── webpack.config.js ├── LICENSE ├── README.md ├── index.ts ├── package.json ├── src ├── InputAutocomplete.tsx └── find.ts ├── stories └── index.tsx ├── tests └── InputAutocomplete.test.tsx ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | 3 | # Github pages 4 | _gh-pages 5 | 6 | # dependencies 7 | /node_modules 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | npm-debug.log 18 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Dev 2 | stories 3 | .storybook 4 | tests 5 | index.ts 6 | tslint.json 7 | build/tests 8 | 9 | # Github pages 10 | _gh-pages 11 | 12 | # Node generated files 13 | node_modules 14 | npm-debug.log 15 | 16 | # OS generated files 17 | Thumbs.db 18 | .DS_Store 19 | 20 | # Ignored files 21 | *.ts 22 | !*.d.ts 23 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@kadira/storybook'; 2 | 3 | function loadStories() { 4 | require('../stories'); 5 | } 6 | 7 | configure(loadStories, module); 8 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = { 3 | module: { 4 | loaders: [ 5 | {test: /\.(tsx|ts)$/, exclude: /node_modules/, loaders: ['ts-loader']} 6 | ] 7 | }, 8 | resolve: { 9 | extensions: ['', '.js', '.jsx', '.tsx', '.ts'] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Kevin J. Hanna 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 | # Input Autocomplete 2 | Tiny react input component with HTML5-like autocomplete. 3 | 4 |  5 | 6 | 7 | ## Why not HTML5 autocomplete? 8 | Because HTML5 autocomplete only show options based on earlier user typed values. 9 | 10 | ## Features: 11 | - Autocomplete based only on given values. 12 | - No styling. Style it yourself as a regular text input element. 13 | - Tiny abstraction over input element. 14 | - Typescript types. 15 | 16 | ## Demo and examples 17 | Live demo: [kevinjhanna.github.io/input-autocomplete](https://kevinjhanna.github.io/input-autocomplete/) 18 | 19 | ## Installation 20 | ``` 21 | npm install input-autocomplete --save 22 | ``` 23 | 24 | ## Usage 25 | 26 | ### Uncontrolled input 27 | ```jsx 28 | import { InputAutocomplete } from 'input-autocomplete' 29 | 30 | 34 | ``` 35 | 36 | ### Controlled input 37 | ```jsx 38 | import { InputAutocomplete } from 'input-autocomplete' 39 | 40 | let state = { 41 | name: '' 42 | } 43 | 44 | const handleOnChange = (ev) => { 45 | state = { 46 | name: ev.currentTarget.value 47 | } 48 | } 49 | 50 | 56 | ``` 57 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export { InputAutocomplete } from './src/InputAutocomplete' 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "input-autocomplete", 3 | "author": "Kevin J. Hanna", 4 | "main": "./build/index.js", 5 | "description": "Tiny react input component with HTML5-like autocomplete.", 6 | "homepage": "https://github.com/kevinjhanna/input-autocomplete", 7 | "license": "MIT", 8 | "types": "./src/InputAutocomplete.d.ts", 9 | "version": "1.0.7", 10 | "keywords": ["react", "autocomplete", "react-autocomplete-input", "tiny", "typescript"], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/kevinjhanna/input-autocomplete" 14 | }, 15 | "devDependencies": { 16 | "@kadira/storybook": "^2.21.0", 17 | "@types/enzyme": "^2.7.4", 18 | "@types/jest": "^18.1.1", 19 | "@types/node": "^7.0.5", 20 | "@types/react": "^15.0.8", 21 | "@types/react-dom": "^0.14.23", 22 | "@types/sinon": "^1.16.35", 23 | "enzyme": "^2.7.1", 24 | "git-directory-deploy": "^1.5.1", 25 | "jest": "^19.0.2", 26 | "react": "^15.4.2", 27 | "react-addons-test-utils": "^15.4.2", 28 | "react-dom": "^15.4.2", 29 | "rimraf": "^2.6.1", 30 | "sinon": "^1.17.7", 31 | "ts-loader": "^2.0.3", 32 | "typescript": "^2.2.1" 33 | }, 34 | "peerDependencies": { 35 | "react": "^15.4.2" 36 | }, 37 | "dependencies": {}, 38 | "scripts": { 39 | "test": "tsc && jest build/tests/*.js", 40 | "pre-publish": "rimraf build/ && npm test", 41 | "storybook": "start-storybook -p 6006", 42 | "gh-pages:clean": "rimraf _gh-pages", 43 | "gh-pages:build": "$(npm bin)/build-storybook -o _gh-pages", 44 | "gh-pages:publish": "$(npm bin)/git-directory-deploy --directory _gh-pages", 45 | "gh-pages": "npm run gh-pages:clean && npm run gh-pages:build && npm run gh-pages:publish" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/InputAutocomplete.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import find from './find' 3 | 4 | export interface Props extends React.HTMLProps { 5 | autocompleteValues: string[] 6 | } 7 | 8 | const update = (written: string, completed: string) => { 9 | return (prevState: State, props: Props) : State => { 10 | return { 11 | written: written, 12 | completed: completed 13 | } 14 | } 15 | } 16 | 17 | export interface State { 18 | written: string 19 | completed: string 20 | } 21 | 22 | export class InputAutocomplete extends React.Component { 23 | constructor(props: Props) { 24 | super(props) 25 | 26 | this.state = { 27 | written: (props.value && String(props.value)) || (props.defaultValue && String(props.defaultValue)) || '', 28 | completed: '', 29 | } 30 | 31 | this.handleOnChange = this.handleOnChange.bind(this) 32 | } 33 | 34 | fireOnChange(ev: React.FormEvent, changedValue?: string) { 35 | if (!this.props.onChange) { 36 | return 37 | } 38 | 39 | if (!changedValue) { 40 | this.props.onChange(ev) 41 | return 42 | } 43 | 44 | const newEvent: React.FormEvent = { 45 | ...ev, 46 | currentTarget: { 47 | ...ev.currentTarget, 48 | value: changedValue 49 | } 50 | } 51 | 52 | this.props.onChange(newEvent) 53 | } 54 | 55 | handleOnChange(ev: React.FormEvent) { 56 | const target = ev.currentTarget 57 | const value = target.value 58 | const performMatch = value.length > this.state.written.length 59 | 60 | if (!performMatch) { 61 | this.fireOnChange(ev) 62 | this.setState(update(value, '')) 63 | return 64 | } 65 | 66 | const match = find(this.props.autocompleteValues, autocompleteValue => autocompleteValue.indexOf(value) == 0) 67 | 68 | if (match) { 69 | this.setState(update(value, match.replace(value, '')), 70 | () => { 71 | target.focus() 72 | target.setSelectionRange(value.length, match.length) 73 | }) 74 | } else { 75 | this.setState(update(value, '')) 76 | } 77 | 78 | this.fireOnChange(ev, match) 79 | } 80 | 81 | render() { 82 | const { autocompleteValues, ...props } = this.props 83 | 84 | return 89 | } 90 | } 91 | 92 | export default InputAutocomplete 93 | -------------------------------------------------------------------------------- /src/find.ts: -------------------------------------------------------------------------------- 1 | const polyfill = (array: T[], predicate: (value: T) => boolean): T | undefined => { 2 | for (let i = 0; i < array.length; i++) { 3 | if (predicate(array[i])) { 4 | return array[i] 5 | } 6 | } 7 | 8 | return undefined 9 | } 10 | 11 | const nativeWrapper = (array: T[], predicate: (value: T) => boolean): T | undefined => { 12 | return array.find(predicate) 13 | } 14 | 15 | export default Array.prototype.find ? nativeWrapper : polyfill 16 | -------------------------------------------------------------------------------- /stories/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { storiesOf, action } from '@kadira/storybook' 3 | import InputAutocomplete from '../src/InputAutocomplete' 4 | 5 | const onChange = (ev: React.FormEvent) => { 6 | return action('input onChange')(ev.currentTarget.value) 7 | } 8 | 9 | const autocompleteValues = [ 10 | 'utm_source', 11 | 'utm_medium', 12 | 'utm_campaign', 13 | 'utm_term', 14 | 'utm_content', 15 | ] 16 | 17 | storiesOf('AutocompleteInput', module) 18 | .addDecorator(story => ( 19 | 20 | Try writing utm_source, utm_medium, utm_campaign, utm_term or utm_content 21 | {story()} 22 | 23 | )) 24 | .add('Uncontrolled input', () => ( 25 | 30 | )) 31 | .add('Controlled input', () => { 32 | class StatefulWrapper extends React.Component<{}, { value: string}> { 33 | constructor() { 34 | super() 35 | this.state = { 36 | value: '' 37 | } 38 | } 39 | 40 | handleOnChange = (ev: React.FormEvent) => { 41 | onChange(ev) 42 | const value = ev.currentTarget.value 43 | this.setState({ value }) 44 | } 45 | 46 | render() { 47 | return 53 | } 54 | } 55 | 56 | return 57 | }) 58 | -------------------------------------------------------------------------------- /tests/InputAutocomplete.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { shallow } from 'enzyme' 3 | import sinon = require('sinon') 4 | import InputAutocomplete from '../src/InputAutocomplete' 5 | 6 | describe('Uncontrolled InputAutocomplete', () => { 7 | it('changes value', () => { 8 | const wrapper = shallow() 11 | 12 | const event = { 13 | currentTarget: { 14 | focus: sinon.stub(), 15 | setSelectionRange: sinon.stub(), 16 | value: 'other', 17 | } 18 | } 19 | 20 | wrapper.find('input').simulate('change', event) 21 | expect(wrapper.find('input').props().value).toBe('other') 22 | }) 23 | 24 | it('autocompletes value', () => { 25 | const wrapper = shallow() 28 | 29 | const event = { 30 | currentTarget: { 31 | focus: sinon.stub(), 32 | setSelectionRange: sinon.stub(), 33 | value: 'f', 34 | } 35 | } 36 | 37 | wrapper.find('input').simulate('change', event) 38 | expect(wrapper.find('input').props().value).toBe('foo') 39 | }) 40 | 41 | it('works with defaultValue', () => { 42 | const wrapper = shallow() 46 | 47 | expect(wrapper.find('input').props().value).toBe('f') 48 | }) 49 | }) 50 | 51 | describe('Controlled InputAutocomplete', () => { 52 | it('sets value', () => { 53 | const wrapper = shallow() 57 | 58 | expect(wrapper.find('input').props().value).toBe('a value') 59 | }) 60 | 61 | it('does not change value when user tries to change it', () => { 62 | const wrapper = shallow() 66 | 67 | const event = { 68 | currentTarget: { 69 | focus: sinon.stub(), 70 | setSelectionRange: sinon.stub(), 71 | value: 'other', 72 | } 73 | } 74 | 75 | wrapper.simulate('change', event) 76 | expect(wrapper.find('input').props().value).toBe('original') 77 | }) 78 | 79 | it('does change value via props', () => { 80 | const wrapper = shallow() 84 | 85 | wrapper.setProps({ 86 | value: 'other' 87 | }) 88 | 89 | expect(wrapper.find('input').props().value).toBe('other') 90 | }) 91 | 92 | it('fires onChange prop', () => { 93 | const handleOnChange = sinon.spy() 94 | 95 | const wrapper = shallow() 99 | 100 | const event = { 101 | currentTarget: { 102 | focus: sinon.stub(), 103 | setSelectionRange: sinon.stub(), 104 | value: 'other', 105 | } 106 | } 107 | 108 | wrapper.simulate('change', event) 109 | expect(handleOnChange.withArgs('other').calledOnce) 110 | }) 111 | 112 | it('fires onChange prop with autocompleted value', () => { 113 | const handleOnChange = sinon.spy() 114 | 115 | const wrapper = shallow() 119 | 120 | const event = { 121 | currentTarget: { 122 | focus: sinon.stub(), 123 | setSelectionRange: sinon.stub(), 124 | value: 'f', 125 | } 126 | } 127 | 128 | wrapper.simulate('change', event) 129 | expect(handleOnChange.withArgs('foo').calledOnce) 130 | }) 131 | }) 132 | 133 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "build", 4 | "module": "commonjs", 5 | "target": "es5", 6 | "lib": [ 7 | "es5", "dom", "es2015" 8 | ], 9 | "sourceMap": true, 10 | "allowJs": false, 11 | "jsx": "react", 12 | "moduleResolution": "node", 13 | "forceConsistentCasingInFileNames": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noUnusedLocals": true, 20 | "declaration": true 21 | }, 22 | "files": [ 23 | "index.ts" 24 | ], 25 | "include": [ 26 | "tests/**/*.test.ts?" 27 | ] 28 | 29 | } 30 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-react"], 3 | "rules": { 4 | "align": [ 5 | true, 6 | "parameters", 7 | "arguments", 8 | "statements" 9 | ], 10 | "ban": false, 11 | "class-name": true, 12 | "comment-format": [ 13 | true, 14 | "check-space" 15 | ], 16 | "curly": true, 17 | "eofline": false, 18 | "forin": true, 19 | "indent": [ true, "spaces" ], 20 | "interface-name": [true, "never-prefix"], 21 | "jsdoc-format": true, 22 | "jsx-no-lambda": false, 23 | "jsx-no-multiline-js": false, 24 | "label-position": true, 25 | "max-line-length": [ true, 120 ], 26 | "member-ordering": [ 27 | true, 28 | "public-before-private", 29 | "static-before-instance", 30 | "variables-before-functions" 31 | ], 32 | "no-any": true, 33 | "no-arg": true, 34 | "no-bitwise": true, 35 | "no-console": [ 36 | true, 37 | "log", 38 | "error", 39 | "debug", 40 | "info", 41 | "time", 42 | "timeEnd", 43 | "trace" 44 | ], 45 | "no-consecutive-blank-lines": true, 46 | "no-construct": true, 47 | "no-debugger": true, 48 | "no-duplicate-variable": true, 49 | "no-empty": true, 50 | "no-eval": true, 51 | "no-shadowed-variable": true, 52 | "no-string-literal": true, 53 | "no-switch-case-fall-through": true, 54 | "no-trailing-whitespace": false, 55 | "no-unused-expression": true, 56 | "no-use-before-declare": true, 57 | "one-line": [ 58 | true, 59 | "check-catch", 60 | "check-else", 61 | "check-open-brace", 62 | "check-whitespace" 63 | ], 64 | "quotemark": [true, "single", "jsx-double"], 65 | "radix": true, 66 | "semicolon": [false, "always"], 67 | "switch-default": true, 68 | 69 | "trailing-comma": false, 70 | 71 | "triple-equals": [ true, "allow-null-check" ], 72 | "typedef": [ 73 | true, 74 | "parameter", 75 | "property-declaration" 76 | ], 77 | "typedef-whitespace": [ 78 | true, 79 | { 80 | "call-signature": "nospace", 81 | "index-signature": "nospace", 82 | "parameter": "nospace", 83 | "property-declaration": "nospace", 84 | "variable-declaration": "nospace" 85 | } 86 | ], 87 | "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore", "allow-pascal-case"], 88 | "whitespace": [ 89 | true, 90 | "check-branch", 91 | "check-decl", 92 | "check-module", 93 | "check-operator", 94 | "check-separator", 95 | "check-type", 96 | "check-typecast" 97 | ] 98 | } 99 | } 100 | --------------------------------------------------------------------------------
Try writing utm_source, utm_medium, utm_campaign, utm_term or utm_content