A fantastically simple tagging component for your React projects. View the readme on GitHub for full installation and usage instructions and available options.
78 |
79 |
Demos
80 |
Country Selector
81 |
82 |
83 |
Custom tags
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
--------------------------------------------------------------------------------
/gh-pages.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -o errexit #abort if any command fails
3 |
4 | deploy_directory=${GIT_DEPLOY_DIR:-example}
5 | deploy_branch=${GIT_DEPLOY_BRANCH:-gh-pages}
6 |
7 | #if no user identity is already set in the current git environment, use this:
8 | default_username=${GIT_DEPLOY_USERNAME:-deploy.sh}
9 | default_email=${GIT_DEPLOY_EMAIL:-}
10 |
11 | #repository to deploy to. must be readable and writable.
12 | repo=${GIT_DEPLOY_REPO:-origin}
13 |
14 | # Parse arg flags
15 | while : ; do
16 | if [[ $1 = "-v" || $1 = "--verbose" ]]; then
17 | verbose=true
18 | shift
19 | elif [[ $1 = "-s" || $1 = "--setup" ]]; then
20 | setup=true
21 | shift
22 | elif [[ $1 = "-e" || $1 = "--allow-empty" ]]; then
23 | allow_empty=true
24 | shift
25 | else
26 | break
27 | fi
28 | done
29 |
30 | #echo expanded commands as they are executed (for debugging)
31 | function enable_expanded_output {
32 | if [ $verbose ]; then
33 | set -o xtrace
34 | set +o verbose
35 | fi
36 | }
37 |
38 | #this is used to avoid outputting the repo URL, which may contain a secret token
39 | function disable_expanded_output {
40 | if [ $verbose ]; then
41 | set +o xtrace
42 | set -o verbose
43 | fi
44 | }
45 |
46 | enable_expanded_output
47 |
48 | function set_user_id {
49 | if [[ -z `git config user.name` ]]; then
50 | git config user.name "$default_username"
51 | fi
52 | if [[ -z `git config user.email` ]]; then
53 | git config user.email "$default_email"
54 | fi
55 | }
56 |
57 | function restore_head {
58 | if [[ $previous_branch = "HEAD" ]]; then
59 | #we weren't on any branch before, so just set HEAD back to the commit it was on
60 | git update-ref --no-deref HEAD $commit_hash $deploy_branch
61 | else
62 | git symbolic-ref HEAD refs/heads/$previous_branch
63 | fi
64 |
65 | git reset --mixed
66 | }
67 |
68 | if ! git diff --exit-code --quiet --cached; then
69 | echo Aborting due to uncommitted changes in the index >&2
70 | exit 1
71 | fi
72 |
73 | commit_title=`git log -n 1 --format="%s" HEAD`
74 | commit_hash=`git log -n 1 --format="%H" HEAD`
75 | previous_branch=`git rev-parse --abbrev-ref HEAD`
76 |
77 | if [ $setup ]; then
78 | mkdir -p "$deploy_directory"
79 | git --work-tree "$deploy_directory" checkout --orphan $deploy_branch
80 | git --work-tree "$deploy_directory" rm -r "*"
81 | git --work-tree "$deploy_directory" add --all
82 | git --work-tree "$deploy_directory" commit -m "initial publish"$'\n\n'"generated from commit $commit_hash"
83 | git push $repo $deploy_branch
84 | restore_head
85 | exit
86 | fi
87 |
88 | if [ ! -d "$deploy_directory" ]; then
89 | echo "Deploy directory '$deploy_directory' does not exist. Aborting." >&2
90 | exit 1
91 | fi
92 |
93 | if [[ -z `ls -A "$deploy_directory" 2> /dev/null` && -z $allow_empty ]]; then
94 | echo "Deploy directory '$deploy_directory' is empty. Aborting. If you're sure you want to deploy an empty tree, use the -e flag." >&2
95 | exit 1
96 | fi
97 |
98 | disable_expanded_output
99 | git fetch --force $repo $deploy_branch:$deploy_branch
100 | enable_expanded_output
101 |
102 | #make deploy_branch the current branch
103 | git symbolic-ref HEAD refs/heads/$deploy_branch
104 |
105 | #put the previously committed contents of deploy_branch branch into the index
106 | git --work-tree "$deploy_directory" reset --mixed --quiet
107 |
108 | git --work-tree "$deploy_directory" add --all
109 |
110 | set +o errexit
111 | diff=$(git --work-tree "$deploy_directory" diff --exit-code --quiet HEAD)$?
112 | set -o errexit
113 | case $diff in
114 | 0) echo No changes to files in $deploy_directory. Skipping commit.;;
115 | 1)
116 | set_user_id
117 | git --work-tree "$deploy_directory" commit -m \
118 | "publish: $commit_title"$'\n\n'"generated from commit $commit_hash"
119 |
120 | disable_expanded_output
121 | #--quiet is important here to avoid outputting the repo URL, which may contain a secret token
122 | git push --quiet $repo $deploy_branch
123 | enable_expanded_output
124 | ;;
125 | *)
126 | echo git diff exited with code $diff. Aborting. Staying on branch $deploy_branch so you can debug. To switch back to master, use: git symbolic-ref HEAD refs/heads/master && git reset --mixed >&2
127 | exit $diff
128 | ;;
129 | esac
130 |
131 | restore_head
132 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 6.3.0
4 |
5 | - Added `clearSelectedIndex` method to programmatically clear the currently selected suggestion ([joelposti](https://github.com/joelposti))
6 | ## 6.2.0
7 |
8 | - Added `newTagText` option to display a prompt in the suggestions list to create a new tag when the `allowNew` option is enabled ([cml391](https://github.com/cml391))
9 | - Refactored call of `onAddition` callback to always provide a new object instead of passing the selected tag by reference.
10 | - Refactored `classNames` option to merge the provided prop with defaults ([alexandernst](https://github.com/alexandernst))
11 | - Fixed updates to the `placeholder` option which did not recalculate input size ([LarsHassler](https://github.com/LarsHassler))
12 | - Updated React peer dependency support to include 17+
13 | - Updated example page with a new demo
14 |
15 | ## 6.1.0
16 |
17 | - Added `suggestionsTransform` option to allow custom filtering and sorting of suggestions ([sibiraj-s](https://github.com/sibiraj-s))
18 |
19 | ## 6.0.0
20 |
21 | - Added `clearInput` method to programmatically clear input text
22 | - Added `suggestionComponent` option to allow the rendering of a custom suggestion component ([tjphopkins](https://github.com/tjphopkins))
23 | - Added `searchWrapper` to `classNames` option
24 | - Added ES6 package and `"module"` entry point
25 | - Added `id` option to configure the component ID
26 | - Added `removeButtonText` option to configure the selected tag remove button title attribute
27 | - Refactored `ariaLabel` option to `ariaLabelText` to match other text options
28 | - Refactored `placeholder` option to `placeholderText` to match other text options
29 | - Refactored keyboard event handlers to use `KeyboardEvent.key`
30 | - Refactored event handlers and callbacks to use `on` prefixes
31 | - Refactored `classNames` option to avoid creating new and merging objects for each top-level props change
32 | - Refactored `deleteTag` method so it no longer clears the input text when a tag is removed
33 | - Refactored `delimiters` option to be an array of `KeyboardEvent.key` values
34 | - Refactored `onInput` callback to provide basic support for `delimiters` entered on soft keyboards
35 | - Removed `clearInputOnDelete` option
36 | - Removed `autofocus` option
37 | - Removed `delimiterChars` option
38 | - Updated React peer dependency to 16.5+
39 |
40 | ## 5.13.1
41 |
42 | - Fixed an issue where cursor focus could be lost after removing a selected tag
43 |
44 | ## 5.13.0
45 |
46 | - Added `ariaLabel` option ([Herdismaria](https://github.com/Herdismaria))
47 |
48 | ## 5.12.1
49 |
50 | - Fixed an issue where the `componentDidUpdate()` callback of the input component can be called too many times
51 |
52 | ## 5.12.0
53 |
54 | - Added `noSuggestionsText` option ([jraack](https://github.com/jraack))
55 |
56 | ## 5.11.2
57 |
58 | - Fixed an issue with the delimiter key logic which would attempt to add a previously selected suggestion even when it was no longer in the suggestion list.
59 |
60 | ## 5.11.1
61 |
62 | - Fixed an issue with suggestion highlighting when the entered query is blank
63 |
64 | ## 5.11.0
65 |
66 | - Added the current query as the second argument for the `suggestionsFilter` option
67 |
68 | ## 5.10.0
69 |
70 | - Added `suggestionsFilter` option ([paulshannon](https://github.com/paulshannon))
71 |
72 | ## 5.9.0
73 |
74 | - Added `clearInputOnDelete` option ([yefrem](https://github.com/yefrem))
75 |
76 | ## 5.8.2
77 |
78 | - Updated contents of package tarball to remove unnecessary files and decrease filesize
79 |
80 | ## 5.8.1
81 |
82 | - Removed unnecessary `componentWillReceiveProps()` method from input component
83 |
84 | ## 5.8.0
85 |
86 | - Added `handleValidate` option ([axelniklasson](https://github.com/axelniklasson))
87 |
88 | ## 5.7.1
89 |
90 | - Fixed missing `onChange` attribute warnings in development mode
91 |
92 | ## 5.7.0
93 |
94 | - Added `addOnBlur` option ([APILLSBURY](https://github.com/APILLSBURY) and [jedrzejiwanicki](https://github.com/jedrzejiwanicki))
95 |
96 | ## 5.6.0
97 |
98 | - Added `inputAttributes` option ([juliettepretot](https://github.com/juliettepretot))
99 |
100 | ## 5.5.0
101 |
102 | - Refactored input into a controlled component (also fixes Preact compatibility)
103 | - Refactored focus and blur handlers to capture events (also fixes Preact compatibility)
104 | - Added `handleFocus` and `handleBlur` callbacks ([Pomax](https://github.com/Pomax))
105 | - Updated dependencies ([ajmas](https://github.com/ajmas))
106 |
107 | ## 5.4.1
108 |
109 | - Fixed return key submitting containing form when `minQueryLength` is set to 0 and suggestions are active ([Drahoslav7](https://github.com/Drahoslav7))
110 |
111 | ## 5.4.0
112 |
113 | - Added `delimiters` property to override keyboard codes for picking suggestions ([Pomax](https://github.com/Pomax))
114 |
115 | ## 5.3.0
116 |
117 | - Updated component compatibility with React v15.5 which silences deprecation warnings
118 | - Refactored examples code away from `createClass` to ES6 syntax
119 |
120 | ## 5.2.0
121 |
122 | - Add `allowBackspace` option to disable the ability to delete the selected tags when backspace is pressed while focussed on the text input
123 | - Refactors `updateInputWidth` method to update when any props change ([@joekrill](https://github.com/joekrill))
124 |
125 | ## 5.1.0
126 |
127 | - Added `tagComponent` option to allow the rendering of a custom tag component
128 |
129 | ## 5.0.4
130 |
131 | - Fixed cursor focus being lost when clicking a suggestion
132 |
133 | ## 5.0.3
134 |
135 | - Fixed word boundary regex restricting suggestions to ascii characters
136 |
137 | ## 5.0.2
138 |
139 | - Fixed unescaped queries throwing an exception when being converted to regexp
140 |
141 | ## 5.0.1
142 |
143 | - Fixed `maxSuggestionsLength` not being passed to suggestions component
144 |
145 | ## 5.0.0
146 |
147 | - Removed `delimiters` option
148 | - Added support for jsnext entry point
149 | - Removed functionality to hide suggestions list when escape is pressed
150 | - Added functionality to hide suggestions list when input is blurred
151 | - Added class name to component root when input is focused
152 | - Refactored components to ES6 class syntax and stateless functions
153 | - Refactored components to use Standard code style
154 | - Refactored `classNames` option to better match usage and use BEM naming convention
155 |
156 | ## 4.3.1
157 |
158 | - Fixed React semver that was too tight
159 |
160 | ## 4.3.0
161 |
162 | - Updated to support React 15.0.0
163 |
164 | ## 4.2.0
165 |
166 | - Added `allowNew` option
167 | - Fixed incorrect partial matches when adding a tag
168 |
169 | ## 4.1.1
170 |
171 | - Fixed mising index from active descendent attribute
172 |
173 | ## 4.1.0
174 |
175 | - Added `classNames` option
176 |
177 | ## 4.0.2
178 |
179 | - Fixed missing `type` attribute from tag buttons
180 |
181 | ## 4.0.1
182 |
183 | - Fixed out of date dist package
184 |
185 | ## 4.0.0
186 |
187 | - Removed `busy` option and status indicator
188 | - Added `maxSuggestionsLength` option
189 |
--------------------------------------------------------------------------------
/lib/ReactTags.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import Tag from './Tag'
4 | import Input from './Input'
5 | import Suggestions from './Suggestions'
6 | import { matchExact, matchPartial } from './concerns/matchers'
7 | import { focusNextElement } from './concerns/focusNextElement'
8 |
9 | const KEYS = {
10 | ENTER: 'Enter',
11 | TAB: 'Tab',
12 | BACKSPACE: 'Backspace',
13 | UP_ARROW: 'ArrowUp',
14 | UP_ARROW_COMPAT: 'Up',
15 | DOWN_ARROW: 'ArrowDown',
16 | DOWN_ARROW_COMPAT: 'Down'
17 | }
18 |
19 | const CLASS_NAMES = {
20 | root: 'react-tags',
21 | rootFocused: 'is-focused',
22 | selected: 'react-tags__selected',
23 | selectedTag: 'react-tags__selected-tag',
24 | selectedTagName: 'react-tags__selected-tag-name',
25 | search: 'react-tags__search',
26 | searchWrapper: 'react-tags__search-wrapper',
27 | searchInput: 'react-tags__search-input',
28 | suggestions: 'react-tags__suggestions',
29 | suggestionActive: 'is-active',
30 | suggestionDisabled: 'is-disabled',
31 | suggestionPrefix: 'react-tags__suggestion-prefix'
32 | }
33 |
34 | function findMatchIndex (options, query) {
35 | return options.findIndex((option) => matchExact(query).test(option.name))
36 | }
37 |
38 | function pressDelimiter () {
39 | if (this.state.query.length >= this.props.minQueryLength) {
40 | // Check if the user typed in an existing suggestion.
41 | const match = findMatchIndex(this.state.options, this.state.query)
42 | const index = this.state.index === -1 ? match : this.state.index
43 | const tag = index > -1 ? this.state.options[index] : null
44 |
45 | if (tag) {
46 | this.addTag(tag)
47 | } else if (this.props.allowNew) {
48 | this.addTag({ name: this.state.query })
49 | }
50 | }
51 | }
52 |
53 | function pressUpKey (e) {
54 | e.preventDefault()
55 |
56 | // if first item, cycle to the bottom
57 | const size = this.state.options.length - 1
58 | this.setState({ index: this.state.index <= 0 ? size : this.state.index - 1 })
59 | }
60 |
61 | function pressDownKey (e) {
62 | e.preventDefault()
63 |
64 | // if last item, cycle to top
65 | const size = this.state.options.length - 1
66 | this.setState({ index: this.state.index >= size ? 0 : this.state.index + 1 })
67 | }
68 |
69 | function pressBackspaceKey () {
70 | // when backspace key is pressed and query is blank, delete the last tag
71 | if (!this.state.query.length) {
72 | this.deleteTag(this.props.tags.length - 1)
73 | }
74 | }
75 |
76 | function defaultSuggestionsFilter (item, query) {
77 | const regexp = matchPartial(query)
78 | return regexp.test(item.name)
79 | }
80 |
81 | function getOptions (props, state) {
82 | let options
83 |
84 | if (props.suggestionsTransform) {
85 | options = props.suggestionsTransform(state.query, props.suggestions)
86 | } else {
87 | options = props.suggestions.filter((item) => props.suggestionsFilter(item, state.query))
88 | }
89 |
90 | options = options.slice(0, props.maxSuggestionsLength)
91 |
92 | if (props.allowNew && props.newTagText && findMatchIndex(options, state.query) === -1) {
93 | options.push({ id: 0, name: state.query, prefix: props.newTagText, disableMarkIt: true })
94 | } else if (props.noSuggestionsText && options.length === 0) {
95 | options.push({ id: 0, name: props.noSuggestionsText, disabled: true, disableMarkIt: true })
96 | }
97 |
98 | return options
99 | }
100 |
101 | class ReactTags extends React.Component {
102 | constructor (props) {
103 | super(props)
104 |
105 | this.state = {
106 | query: '',
107 | focused: false,
108 | index: -1
109 | }
110 |
111 | this.inputEventHandlers = {
112 | // Provide a no-op function to the input component to avoid warnings
113 | //
114 | //
115 | onChange: () => {},
116 | onBlur: this.onBlur.bind(this),
117 | onFocus: this.onFocus.bind(this),
118 | onInput: this.onInput.bind(this),
119 | onKeyDown: this.onKeyDown.bind(this)
120 | }
121 |
122 | this.container = React.createRef()
123 | this.input = React.createRef()
124 | }
125 |
126 | onInput (e) {
127 | const query = e.target.value
128 |
129 | if (this.props.onInput) {
130 | this.props.onInput(query)
131 | }
132 |
133 | // NOTE: This test is a last resort for soft keyboards and browsers which do not
134 | // support `KeyboardEvent.key`.
135 | //
136 | //
137 | if (
138 | query.length === this.state.query.length + 1 &&
139 | this.props.delimiters.indexOf(query.slice(-1)) > -1
140 | ) {
141 | pressDelimiter.call(this)
142 | } else if (query !== this.state.query) {
143 | this.setState({ query })
144 | }
145 | }
146 |
147 | onKeyDown (e) {
148 | // when one of the terminating keys is pressed, add current query to the tags
149 | if (this.props.delimiters.indexOf(e.key) > -1) {
150 | if (this.state.query || this.state.index > -1) {
151 | e.preventDefault()
152 | }
153 |
154 | pressDelimiter.call(this)
155 | }
156 |
157 | // when backspace key is pressed and query is blank, delete the last tag
158 | if (e.key === KEYS.BACKSPACE && this.props.allowBackspace) {
159 | pressBackspaceKey.call(this, e)
160 | }
161 |
162 | if (e.key === KEYS.UP_ARROW || e.key === KEYS.UP_ARROW_COMPAT) {
163 | pressUpKey.call(this, e)
164 | }
165 |
166 | if (e.key === KEYS.DOWN_ARROW || e.key === KEYS.DOWN_ARROW_COMPAT) {
167 | pressDownKey.call(this, e)
168 | }
169 | }
170 |
171 | onClick (e) {
172 | if (document.activeElement !== e.target) {
173 | this.focusInput()
174 | }
175 | }
176 |
177 | onBlur () {
178 | this.setState({ focused: false, index: -1 })
179 |
180 | if (this.props.onBlur) {
181 | this.props.onBlur()
182 | }
183 |
184 | if (this.props.addOnBlur) {
185 | pressDelimiter.call(this)
186 | }
187 | }
188 |
189 | onFocus () {
190 | this.setState({ focused: true })
191 |
192 | if (this.props.onFocus) {
193 | this.props.onFocus()
194 | }
195 | }
196 |
197 | onDeleteTag (index, event) {
198 | // Because we'll destroy the element with cursor focus we need to ensure
199 | // it does not get lost and move it to the next interactive element
200 | if (this.container.current) {
201 | focusNextElement(this.container.current, event.currentTarget)
202 | }
203 |
204 | this.deleteTag(index)
205 | }
206 |
207 | addTag (tag) {
208 | if (tag.disabled) {
209 | return
210 | }
211 |
212 | if (typeof this.props.onValidate === 'function' && !this.props.onValidate(tag)) {
213 | return
214 | }
215 |
216 | this.props.onAddition({ id: tag.id, name: tag.name })
217 |
218 | this.clearInput()
219 | }
220 |
221 | deleteTag (i) {
222 | this.props.onDelete(i)
223 | }
224 |
225 | clearInput () {
226 | this.setState({
227 | query: '',
228 | index: -1
229 | })
230 | }
231 |
232 | clearSelectedIndex () {
233 | this.setState({ index: -1 })
234 | }
235 |
236 | focusInput () {
237 | if (this.input.current && this.input.current.input.current) {
238 | this.input.current.input.current.focus()
239 | }
240 | }
241 |
242 | render () {
243 | const TagComponent = this.props.tagComponent || Tag
244 |
245 | const expanded = this.state.focused && this.state.query.length >= this.props.minQueryLength
246 | const classNames = Object.assign({}, CLASS_NAMES, this.props.classNames)
247 | const rootClassNames = [classNames.root]
248 |
249 | this.state.focused && rootClassNames.push(classNames.rootFocused)
250 |
251 | return (
252 |