@rnwelsh/react-tag-autocomplete
Version:
React Tag Autocomplete is a simple tagging component ready to drop in your React projects.
547 lines (449 loc) • 19.5 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('react'), require('prop-types')) :
typeof define === 'function' && define.amd ? define(['react', 'prop-types'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.ReactTags = factory(global.React, global.PropTypes));
})(this, (function (React, PropTypes) { 'use strict';
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var React__default = /*#__PURE__*/_interopDefaultLegacy(React);
var PropTypes__default = /*#__PURE__*/_interopDefaultLegacy(PropTypes);
function Tag (props) {
return (
React__default["default"].createElement( 'button', { type: 'button', className: props.classNames.selectedTag, title: props.removeButtonText, onClick: props.onDelete },
React__default["default"].createElement( 'span', { className: props.classNames.selectedTagName }, props.tag.name)
)
)
}
var Tag$1 = React__default["default"].memo(Tag);
var SIZER_STYLES = {
position: 'absolute',
width: 0,
height: 0,
visibility: 'hidden',
overflow: 'scroll',
whiteSpace: 'pre'
};
var STYLE_PROPS = [
'fontSize',
'fontFamily',
'fontWeight',
'fontStyle',
'letterSpacing',
'textTransform'
];
var Input = /*@__PURE__*/(function (superclass) {
function Input (props) {
superclass.call(this, props);
this.state = { inputWidth: null };
this.input = React__default["default"].createRef();
this.sizer = React__default["default"].createRef();
}
if ( superclass ) Input.__proto__ = superclass;
Input.prototype = Object.create( superclass && superclass.prototype );
Input.prototype.constructor = Input;
Input.prototype.componentDidMount = function componentDidMount () {
if (this.props.autoresize) {
this.copyInputStyles();
this.updateInputWidth();
}
};
Input.prototype.componentDidUpdate = function componentDidUpdate (ref) {
var query = ref.query;
var placeholderText = ref.placeholderText;
if (query !== this.props.query || placeholderText !== this.props.placeholderText) {
this.updateInputWidth();
}
};
Input.prototype.copyInputStyles = function copyInputStyles () {
var this$1$1 = this;
var inputStyle = window.getComputedStyle(this.input.current);
STYLE_PROPS.forEach(function (prop) {
this$1$1.sizer.current.style[prop] = inputStyle[prop];
});
};
Input.prototype.updateInputWidth = function updateInputWidth () {
var inputWidth;
if (this.props.autoresize) {
// scrollWidth is designed to be fast not accurate.
// +2 is completely arbitrary but does the job.
inputWidth = Math.ceil(this.sizer.current.scrollWidth) + 2;
}
if (inputWidth !== this.state.inputWidth) {
this.setState({ inputWidth: inputWidth });
}
};
Input.prototype.render = function render () {
var ref = this.props;
var id = ref.id;
var query = ref.query;
var ariaLabelText = ref.ariaLabelText;
var placeholderText = ref.placeholderText;
var expanded = ref.expanded;
var classNames = ref.classNames;
var inputAttributes = ref.inputAttributes;
var inputEventHandlers = ref.inputEventHandlers;
var index = ref.index;
return (
React__default["default"].createElement( 'div', { className: classNames.searchWrapper },
React__default["default"].createElement( 'input', Object.assign({},
inputAttributes, inputEventHandlers, { ref: this.input, value: query, placeholder: placeholderText, className: classNames.searchInput, role: 'combobox', 'aria-autocomplete': 'list', 'aria-label': ariaLabelText || placeholderText, 'aria-owns': id, 'aria-activedescendant': index > -1 ? (id + "-" + index) : null, 'aria-expanded': expanded, style: { width: this.state.inputWidth } })),
React__default["default"].createElement( 'div', { ref: this.sizer, style: SIZER_STYLES }, query || placeholderText)
)
)
};
return Input;
}(React__default["default"].Component));
var Input$1 = React__default["default"].memo(Input);
function escapeForRegExp (string) {
return string.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&')
}
function matchAny (string) {
return new RegExp(escapeForRegExp(string), 'gi')
}
function matchPartial (string) {
return new RegExp(("(?:^|\\s)" + (escapeForRegExp(string))), 'i')
}
function matchExact (string) {
return new RegExp(("^" + (escapeForRegExp(string)) + "$"), 'i')
}
function markIt (name, query) {
var regexp = matchAny(query);
return name.replace(regexp, '<mark>$&</mark>')
}
function DefaultSuggestionComponent (ref) {
var item = ref.item;
var query = ref.query;
return (
React__default["default"].createElement( 'span', { dangerouslySetInnerHTML: { __html: markIt(item.name, query) } })
)
}
function Suggestions (props) {
var SuggestionComponent = props.suggestionComponent || DefaultSuggestionComponent;
var options = props.options.map(function (item, index) {
var key = (props.id) + "-" + index;
var classNames = [];
if (props.index === index) {
classNames.push(props.classNames.suggestionActive);
}
if (item.disabled) {
classNames.push(props.classNames.suggestionDisabled);
}
return (
React__default["default"].createElement( 'li', {
id: key, key: key, role: 'option', className: classNames.join(' '), 'aria-disabled': Boolean(item.disabled), onMouseDown: function (e) { return e.preventDefault(); }, onClick: function () { return props.addTag(item); } },
item.prefix
? React__default["default"].createElement( 'span', { className: props.classNames.suggestionPrefix }, item.prefix, ' ')
: null,
item.disableMarkIt
? item.name
: React__default["default"].createElement( SuggestionComponent, { item: item, query: props.query })
)
)
});
return (
React__default["default"].createElement( 'div', { className: props.classNames.suggestions },
React__default["default"].createElement( 'ul', { role: 'listbox', id: props.id }, options)
)
)
}
var Suggestions$1 = React__default["default"].memo(Suggestions);
function focusNextElement (scope, currentTarget) {
var interactiveEls = scope.querySelectorAll('a,button,input');
var currentEl = Array.prototype.findIndex.call(
interactiveEls,
function (element) { return element === currentTarget; }
);
var nextEl = interactiveEls[currentEl - 1] || interactiveEls[currentEl + 1];
if (nextEl) {
nextEl.focus();
}
}
var KEYS = {
ENTER: 'Enter',
TAB: 'Tab',
BACKSPACE: 'Backspace',
UP_ARROW: 'ArrowUp',
UP_ARROW_COMPAT: 'Up',
DOWN_ARROW: 'ArrowDown',
DOWN_ARROW_COMPAT: 'Down'
};
var CLASS_NAMES = {
root: 'react-tags',
rootFocused: 'is-focused',
selected: 'react-tags__selected',
selectedTag: 'react-tags__selected-tag',
selectedTagName: 'react-tags__selected-tag-name',
search: 'react-tags__search',
searchWrapper: 'react-tags__search-wrapper',
searchInput: 'react-tags__search-input',
suggestions: 'react-tags__suggestions',
suggestionActive: 'is-active',
suggestionDisabled: 'is-disabled',
suggestionPrefix: 'react-tags__suggestion-prefix'
};
function findMatchIndex (options, query) {
return options.findIndex(function (option) { return matchExact(query).test(option.name); })
}
function pressDelimiter () {
if (this.state.query.length >= this.props.minQueryLength) {
// Check if the user typed in an existing suggestion.
var match = findMatchIndex(this.state.options, this.state.query);
var index = this.state.index === -1 ? match : this.state.index;
var tag = index > -1 ? this.state.options[index] : null;
if (tag) {
this.addTag(tag);
} else if (this.props.allowNew) {
this.addTag({ name: this.state.query });
}
}
}
function pressUpKey (e) {
e.preventDefault();
// if first item, cycle to the bottom
var size = this.state.options.length - 1;
this.setState({ index: this.state.index <= 0 ? size : this.state.index - 1 });
}
function pressDownKey (e) {
e.preventDefault();
// if last item, cycle to top
var size = this.state.options.length - 1;
this.setState({ index: this.state.index >= size ? 0 : this.state.index + 1 });
}
function pressBackspaceKey () {
// when backspace key is pressed and query is blank, delete the last tag
if (!this.state.query.length) {
this.deleteTag(this.props.tags.length - 1);
}
}
function defaultSuggestionsFilter (item, query) {
var regexp = matchPartial(query);
return regexp.test(item.name)
}
function getOptions (props, state) {
var options;
if (props.suggestionsTransform) {
options = props.suggestionsTransform(state.query, props.suggestions);
} else {
options = props.suggestions.filter(function (item) { return props.suggestionsFilter(item, state.query); });
}
options = options.slice(0, props.maxSuggestionsLength);
if (props.allowNew && props.newTagText && findMatchIndex(options, state.query) === -1) {
options.push({ id: 0, name: state.query, prefix: props.newTagText, disableMarkIt: true });
} else if (props.noSuggestionsText && options.length === 0) {
options.push({ id: 0, name: props.noSuggestionsText, disabled: true, disableMarkIt: true });
}
return options
}
var ReactTags = /*@__PURE__*/(function (superclass) {
function ReactTags (props) {
superclass.call(this, props);
this.state = {
query: '',
focused: false,
index: -1
};
this.inputEventHandlers = {
// Provide a no-op function to the input component to avoid warnings
// <https://github.com/i-like-robots/react-tags/issues/135>
// <https://github.com/facebook/react/issues/13835>
onChange: function () {},
onBlur: this.onBlur.bind(this),
onFocus: this.onFocus.bind(this),
onInput: this.onInput.bind(this),
onKeyDown: this.onKeyDown.bind(this)
};
this.container = React__default["default"].createRef();
this.input = React__default["default"].createRef();
}
if ( superclass ) ReactTags.__proto__ = superclass;
ReactTags.prototype = Object.create( superclass && superclass.prototype );
ReactTags.prototype.constructor = ReactTags;
ReactTags.prototype.onInput = function onInput (e) {
var query = e.target.value;
if (this.props.onInput) {
this.props.onInput(query);
}
// NOTE: This test is a last resort for soft keyboards and browsers which do not
// support `KeyboardEvent.key`.
// <https://bugs.chromium.org/p/chromium/issues/detail?id=763559>
// <https://bugs.chromium.org/p/chromium/issues/detail?id=118639>
if (
query.length === this.state.query.length + 1 &&
this.props.delimiters.indexOf(query.slice(-1)) > -1
) {
pressDelimiter.call(this);
} else if (query !== this.state.query) {
this.setState({ query: query });
}
};
ReactTags.prototype.onKeyDown = function onKeyDown (e) {
// when one of the terminating keys is pressed, add current query to the tags
if (this.props.delimiters.indexOf(e.key) > -1) {
if (this.state.query || this.state.index > -1) {
e.preventDefault();
}
pressDelimiter.call(this);
}
// when backspace key is pressed and query is blank, delete the last tag
if (e.key === KEYS.BACKSPACE && this.props.allowBackspace) {
pressBackspaceKey.call(this, e);
}
if (e.key === KEYS.UP_ARROW || e.key === KEYS.UP_ARROW_COMPAT) {
pressUpKey.call(this, e);
}
if (e.key === KEYS.DOWN_ARROW || e.key === KEYS.DOWN_ARROW_COMPAT) {
pressDownKey.call(this, e);
}
};
ReactTags.prototype.onClick = function onClick (e) {
if (document.activeElement !== e.target) {
this.focusInput();
}
};
ReactTags.prototype.onBlur = function onBlur () {
this.setState({ focused: false, index: -1 });
if (this.props.onBlur) {
this.props.onBlur();
}
if (this.props.addOnBlur) {
pressDelimiter.call(this);
}
};
ReactTags.prototype.onFocus = function onFocus () {
this.setState({ focused: true });
if (this.props.onFocus) {
this.props.onFocus();
}
};
ReactTags.prototype.onDeleteTag = function onDeleteTag (index, event) {
// Because we'll destroy the element with cursor focus we need to ensure
// it does not get lost and move it to the next interactive element
if (this.container.current) {
focusNextElement(this.container.current, event.currentTarget);
}
this.deleteTag(index);
};
ReactTags.prototype.addTag = function addTag (tag) {
if (tag.disabled) {
return
}
if (typeof this.props.onValidate === 'function' && !this.props.onValidate(tag)) {
return
}
this.props.onAddition({ id: tag.id, name: tag.name });
this.clearInput();
};
ReactTags.prototype.deleteTag = function deleteTag (i) {
this.props.onDelete(i);
};
ReactTags.prototype.clearInput = function clearInput () {
this.setState({
query: '',
index: -1
});
};
ReactTags.prototype.clearSelectedIndex = function clearSelectedIndex () {
this.setState({ index: -1 });
};
ReactTags.prototype.focusInput = function focusInput () {
if (this.input.current && this.input.current.input.current) {
this.input.current.input.current.focus();
}
};
ReactTags.prototype.render = function render () {
var this$1$1 = this;
var TagComponent = this.props.tagComponent || Tag$1;
var expanded = this.state.focused && this.state.query.length >= this.props.minQueryLength;
var classNames = Object.assign({}, CLASS_NAMES, this.props.classNames);
var rootClassNames = [classNames.root];
this.state.focused && rootClassNames.push(classNames.rootFocused);
return (
React__default["default"].createElement( 'div', { ref: this.container, className: rootClassNames.join(' '), onClick: this.onClick.bind(this) },
React__default["default"].createElement( 'div', {
className: classNames.selected, 'aria-relevant': 'additions removals', 'aria-live': 'polite' },
this.props.tags.map(function (tag, i) { return (
React__default["default"].createElement( TagComponent, {
key: i, tag: tag, removeButtonText: this$1$1.props.removeButtonText, classNames: classNames, onDelete: this$1$1.onDeleteTag.bind(this$1$1, i) })
); })
),
React__default["default"].createElement( 'div', { className: classNames.search },
React__default["default"].createElement( Input$1, Object.assign({},
this.state, { id: this.props.id, ref: this.input, classNames: classNames, inputAttributes: this.props.inputAttributes, inputEventHandlers: this.inputEventHandlers, autoresize: this.props.autoresize, expanded: expanded, placeholderText: this.props.placeholderText, ariaLabelText: this.props.ariaLabelText })),
expanded && this.state.options.length
? React__default["default"].createElement( Suggestions$1, Object.assign({},
this.state, { id: this.props.id, classNames: classNames, expanded: expanded, addTag: this.addTag.bind(this), suggestionComponent: this.props.suggestionComponent }))
: null
)
)
)
};
ReactTags.getDerivedStateFromProps = function getDerivedStateFromProps (props, state) {
if (state.prevQuery !== state.query || state.prevSuggestions !== props.suggestions) {
return {
prevQuery: state.query,
prevSuggestions: props.suggestions,
options: getOptions(props, state)
}
}
return null
};
return ReactTags;
}(React__default["default"].Component));
ReactTags.defaultProps = {
id: 'ReactTags',
tags: [],
placeholderText: 'Add new tag',
removeButtonText: 'Click to remove tag',
noSuggestionsText: null,
newTagText: null,
suggestions: [],
suggestionsFilter: defaultSuggestionsFilter,
suggestionsTransform: null,
autoresize: true,
classNames: CLASS_NAMES,
delimiters: [KEYS.TAB, KEYS.ENTER],
minQueryLength: 2,
maxSuggestionsLength: 6,
allowNew: false,
allowBackspace: true,
addOnBlur: false,
tagComponent: null,
suggestionComponent: null,
inputAttributes: {}
};
ReactTags.propTypes = {
id: PropTypes__default["default"].string,
tags: PropTypes__default["default"].arrayOf(PropTypes__default["default"].object),
placeholderText: PropTypes__default["default"].string,
ariaLabelText: PropTypes__default["default"].string,
removeButtonText: PropTypes__default["default"].string,
noSuggestionsText: PropTypes__default["default"].string,
newTagText: PropTypes__default["default"].string,
suggestions: PropTypes__default["default"].arrayOf(PropTypes__default["default"].object),
suggestionsFilter: PropTypes__default["default"].func,
suggestionsTransform: PropTypes__default["default"].func,
autoresize: PropTypes__default["default"].bool,
delimiters: PropTypes__default["default"].arrayOf(PropTypes__default["default"].string),
onDelete: PropTypes__default["default"].func.isRequired,
onAddition: PropTypes__default["default"].func.isRequired,
onInput: PropTypes__default["default"].func,
onFocus: PropTypes__default["default"].func,
onBlur: PropTypes__default["default"].func,
onValidate: PropTypes__default["default"].func,
minQueryLength: PropTypes__default["default"].number,
maxSuggestionsLength: PropTypes__default["default"].number,
classNames: PropTypes__default["default"].object,
allowNew: PropTypes__default["default"].bool,
allowBackspace: PropTypes__default["default"].bool,
addOnBlur: PropTypes__default["default"].bool,
tagComponent: PropTypes__default["default"].oneOfType([
PropTypes__default["default"].func,
PropTypes__default["default"].element
]),
suggestionComponent: PropTypes__default["default"].oneOfType([
PropTypes__default["default"].func,
PropTypes__default["default"].element
]),
inputAttributes: PropTypes__default["default"].object
};
return ReactTags;
}));