@vickram/react-auto-complete
Version:
React auto-complete plugin
1,299 lines (1,112 loc) • 33.4 kB
JavaScript
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import ReactDOMServer from 'react-dom/server';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import './AutoComplete.css';
const WINDOW = global.window;
const helperService = new HelperService();
export default class AutoComplete extends React.Component {
constructor(props) {
super(props);
this._instanceId = helperService.registerComponent(this);
this._bindMethods();
this._originalSearchText = null;
this._queryCounter = 0;
this._endOfPagedList = false;
this._currentPageIndex = 0;
this._elementComponent = null;
this.state = {
searchText: null,
dataLoadInProgress: false,
containerVisible: this.props.isInline,
selectedIndex: -1,
renderItems: []
};
}
componentDidMount() {
this._subscribeDOMEvents();
const publicApi = {
positionDropdown: this._positionDropdownIfVisible.bind(this),
hideDropdown: this._hideDropdown.bind(this)
};
this._safeCallback(this.props.ready, publicApi);
}
componentDidUpdate() {
if (this.state.positionDropdownUsingJQuery) {
this._positionUsingJQuery();
}
}
componentWillUnmount() {
this._unsubscribeDOMEvents();
if (this._container) {
this._container = null;
}
}
initialize(target) {
this._target = target;
this._subscribeTargetDOMEvents();
}
render() {
if (this.props.isInline) {
return this._getAutoCompleteList();
}
if (_.isEmpty(this.props.children)) {
return ReactDOM.createPortal(
this._getContainer(),
WINDOW.document.body,
);
}
const children = React.cloneElement(this.props.children, {
ref: (element) => {
if (!element || element === this._elementComponent) {
return;
}
this._elementComponent = element;
if (element.tagName.toUpperCase() === 'INPUT') {
return this.initialize(element);
}
const inputElement = element.querySelector('input');
if (inputElement) {
return this.initialize(inputElement);
}
console.warn('No input element was found in props.children collection');
return null;
}
});
return (
<React.Fragment>
{children}
{this._getContainer()}
</React.Fragment>
);
}
_bindMethods() {
this._handleWindowResize = this._handleWindowResize.bind(this);
this._handleDocumentKeyDown = this._handleDocumentKeyDown.bind(this);
this._handleDocumentClick = this._handleDocumentClick.bind(this);
this._handleTargetFocus = this._handleTargetFocus.bind(this);
this._handleTargetInput = this._handleTargetInput.bind(this);
this._handleTargetKeyDown = this._handleTargetKeyDown.bind(this);
this._handleScroll = this._handleScroll.bind(this);
this._safeCallback = this._safeCallback.bind(this);
this._show = this._show.bind(this);
this._queryAndRender = this._queryAndRender.bind(this);
this._getSelectedCssClass = this._getSelectedCssClass.bind(this);
this._selectItem = this._selectItem.bind(this);
this.initialize = this.initialize.bind(this);
}
_getContainer() {
const classNames = classnames(
this.props.containerCssClass,
'auto-complete-container unselectable',
{ 'auto-complete-absolute-container': !this.props.isInline }
);
return (
<div
className={classNames}
data-instance-id={this._instanceId}
style={this._getContainerInlineStyle()}
ref={x => this._container = x}
>
{this._getAutoCompleteList()}
</div >
);
}
_getContainerInlineStyle() {
return {
display: (this.state.containerVisible ? 'block' : 'none'),
width: this.state.dropdownWidth,
...this.state.containerPosition
};
}
_getAutoCompleteList() {
return (
<AutoCompleteList
ref={x => this._autoCompleteList = x}
searchText={this.state.searchText}
items={this.state.renderItems}
selectedIndex={this.state.selectedIndex}
dropdownHeight={this.state.dropdownHeight}
noMatchItemEnabled={this.props.noMatchItemEnabled}
renderNoMatchItem={this.props.renderNoMatchItem}
getSelectedCssClass={this._getSelectedCssClass}
onItemClick={this._selectItem}
onScroll={this._handleScroll}
/>
);
}
_subscribeDOMEvents() {
WINDOW.addEventListener(DOM_EVENT.RESIZE, this._handleWindowResize);
WINDOW.document.addEventListener(DOM_EVENT.KEYDOWN, this._handleDocumentKeyDown);
WINDOW.document.addEventListener(DOM_EVENT.CLICK, this._handleDocumentClick);
}
_subscribeTargetDOMEvents() {
if (this._target) {
this._target.addEventListener(DOM_EVENT.FOCUS, this._handleTargetFocus);
this._target.addEventListener(DOM_EVENT.INPUT, this._handleTargetInput);
this._target.addEventListener(DOM_EVENT.KEYDOWN, this._handleTargetKeyDown);
}
}
_unsubscribeDOMEvents() {
WINDOW.removeEventListener(DOM_EVENT.RESIZE, this._handleWindowResize);
WINDOW.document.removeEventListener(DOM_EVENT.KEYDOWN, this._handleDocumentKeyDown);
WINDOW.document.removeEventListener(DOM_EVENT.CLICK, this._handleDocumentClick);
if (this._target) {
this._target.removeEventListener(DOM_EVENT.FOCUS, this._handleTargetFocus);
this._target.removeEventListener(DOM_EVENT.INPUT, this._handleTargetInput);
this._target.removeEventListener(DOM_EVENT.KEYDOWN, this._handleTargetKeyDown);
}
}
_handleWindowResize(event) {
if (this.props.hideDropdownOnWindowResize) {
this._autoHide();
}
}
_handleDocumentKeyDown() {
// hide inactive dropdowns when multiple auto complete exist on a page
helperService.hideAllInactive();
}
_handleDocumentClick(event) {
// hide inactive dropdowns when multiple auto complete exist on a page
helperService.hideAllInactive();
// ignore inline
if (this.props.isInline) {
return;
}
// no container. probably unmounted
if (!this._container) {
return;
}
// ignore target click
if (event.target === this._target) {
event.stopPropagation();
return;
}
if (this._containerContainsTarget(event.target)) {
event.stopPropagation();
return;
}
this._autoHide();
}
_containerContainsTarget(target) {
// use native Node.contains
// https://developer.mozilla.org/en-US/docs/Web/API/Node/contains
if (_.isFunction(this._container.contains) && this._container.contains(target)) {
return true;
}
// otherwise use .has() if jQuery is available
if (WINDOW.jQuery) {
const $container = WINDOW.jQuery(this._container);
if (_.isFunction($container.has) && $container.has(target).length > 0) {
return true;
}
}
// assume target is not in container
return false;
}
_handleTargetFocus(event) {
// when the target(textbox) gets focus activate the corresponding container
this._activate();
if (this.props.activateOnFocus) {
this._waitAndQuery(event.target.value, 100);
}
}
_handleTargetInput(event) {
this._tryQuery(event.target.value);
}
_handleTargetKeyDown(event) {
const keyCode = event.charCode || event.keyCode || 0;
if (ignoreKeyCode(keyCode)) {
return;
}
switch (keyCode) {
case KEYCODE.UPARROW:
this._scrollToPreviousItem();
event.stopPropagation();
event.preventDefault();
break;
case KEYCODE.DOWNARROW:
this._scrollToNextItem();
event.stopPropagation();
event.preventDefault();
break;
case KEYCODE.ENTER:
this._selectItem(this.state.selectedIndex, true);
//prevent postback upon hitting enter
event.preventDefault();
event.stopPropagation();
break;
case KEYCODE.ESCAPE:
this._restoreOriginalText();
this._autoHide();
event.preventDefault();
event.stopPropagation();
break;
default:
break;
}
}
_handleScroll(event) {
if (!this.props.pagingEnabled || !this.state.containerVisible) {
return;
}
const scrollList = event.target;
// scrolled to the bottom?
if ((scrollList.offsetHeight + scrollList.scrollTop) >= scrollList.scrollHeight) {
this._tryLoadNextPage();
}
}
_activate() {
helperService.setActiveInstanceId(this._instanceId);
// do not reset if the container (dropdown list) is currently visible
// Ex: Switching to a different tab or window and switching back
// again when the dropdown list is visible.
if (!this.state.containerVisible) {
this._originalSearchText = null;
}
}
_resetAndQuery(searchText) {
this._empty();
this._reset();
return this._query(searchText, 0);
}
_show() {
// the show() method is called after the items are ready for display
// the textbox position can change (ex: window resize) when it has focus
// so reposition the dropdown before it's shown
this._positionDropdown();
// callback
this._safeCallback(this.props.dropdownShown);
}
_autoHide() {
if (this.props.autoHideDropdown) {
this._hideDropdown();
}
}
_empty() {
this.setState({
selectedIndex: -1,
renderItems: []
});
}
_restoreOriginalText() {
if (!this._originalSearchText) {
return;
}
this._target.value = this._originalSearchText;
}
_scrollToPreviousItem() {
const itemIndex = this._getItemIndexFromOffset(-1);
if (itemIndex === -1) {
return;
}
this._scrollToItem(itemIndex);
}
_scrollToNextItem() {
const itemIndex = this._getItemIndexFromOffset(1);
if (itemIndex === -1) {
return;
}
this._scrollToItem(itemIndex);
if (this._shouldLoadNextPageAtItemIndex(itemIndex)) {
this._loadNextPage();
}
}
/**
* @param {number} itemOffset
*/
_getItemIndexFromOffset(itemOffset) {
const itemIndex = this.state.selectedIndex + itemOffset;
if (itemIndex >= this.state.renderItems.length) {
return -1;
}
return itemIndex;
}
/**
* @param {number} itemIndex
*/
_scrollToItem(itemIndex) {
if (!this.state.containerVisible) {
return;
}
this._selectItem(itemIndex);
this._autoCompleteList.scrollToItem(itemIndex);
}
_selectItem(itemIndex, closeDropdownAndRaiseCallback) {
const item = this.state.renderItems[itemIndex];
if (!item) {
return;
}
this.setState({ selectedIndex: itemIndex });
this._updateTarget(item);
if (closeDropdownAndRaiseCallback) {
this._autoHide();
this._safeCallback(this.props.itemSelected, { item: item.data });
}
}
/**
* @param {number} itemIndex
* @returns {string}
*/
_getSelectedCssClass(itemIndex) {
return (itemIndex === this.state.selectedIndex) ? this.props.selectedCssClass : '';
}
/**
* @param {string} searchText
*/
_tryQuery(searchText) {
// query only if minimum number of chars are typed; else hide dropdown
if ((this.props.minimumChars === 0)
|| (searchText && searchText.length >= this.props.minimumChars)) {
this._waitAndQuery(searchText);
return;
}
this._autoHide();
}
/**
* @param {string} searchText
* @param {number} delay
*/
_waitAndQuery(searchText, delay) {
// wait few millisecs before calling query(); this to check if the user has stopped typing
let timer = setTimeout(function () {
// has searchText unchanged?
if (searchText === this._target.value) {
this._resetAndQuery(searchText);
}
//cancel the timeout
clearTimeout(timer);
}.bind(this), (delay || 300));
}
_tryLoadNextPage() {
if (this._shouldLoadNextPage()) {
this._loadNextPage();
}
}
_loadNextPage() {
return this._query(this._originalSearchText, (this._currentPageIndex + 1));
}
/**
* @param {string} searchText
* @param {number} pageIndex
*/
_query(searchText, pageIndex) {
/** @type {QueryArgs} */
const queryArgs = {
searchText: searchText,
paging: {
pageIndex: pageIndex,
pageSize: this.props.pageSize
},
queryId: ++this._queryCounter
};
const renderListFn = (this.props.pagingEnabled ? this._renderPagedList : this._renderList);
return this._queryAndRender(queryArgs, renderListFn.bind(this, queryArgs));
}
/**
* @param {QueryArgs} queryArgs
* @param {function(Array): Promise} renderListFn
*/
_queryAndRender(queryArgs, renderListFn) {
const options = this.props;
// backup original search term in case we need to restore if user hits ESCAPE
this._originalSearchText = queryArgs.searchText;
this.setState({
dataLoadInProgress: true,
searchText: queryArgs.searchText
});
this._safeCallback(options.loading);
return Promise
.resolve(options.data(queryArgs.searchText, queryArgs.paging))
.then(data => {
// verify that the queryId did not change since the possibility exists that the
// search text changed before the 'data' promise was resolved. Say, due to a lag
// in getting data from a remote web service.
if (this._didQueryIdChange(queryArgs)) {
this._autoHide();
return;
}
if (this._shouldHideDropdown(queryArgs, data)) {
this._autoHide();
return;
}
renderListFn(data).then(this._show);
// callback
this._safeCallback(options.loadingComplete);
})
.catch(error => {
// callback
this._safeCallback(options.loadingComplete, { error: error });
})
.finally(() => {
this.setState({ dataLoadInProgress: false });
});
}
/**
* @param {function()} callback
* @param {Object} callbackArgs
*/
_safeCallback(callback, callbackArgs) {
try {
if (_.isFunction(callback)) {
callback.call(this._target, callbackArgs);
}
} catch (ex) {
//ignore
}
}
_positionDropdownIfVisible() {
if (this.state.containerVisible) {
this._positionDropdown();
}
}
_positionDropdown() {
// no need to position if container is inline
if (this.props.isInline) {
return;
}
const options = this.props;
let width = null;
if (options.dropdownWidth && options.dropdownWidth !== 'auto') {
width = options.dropdownWidth;
}
else {
// same as textbox width
width = this._target.getBoundingClientRect().width + 'px';
}
let height = options.dropdownHeight ? (options.dropdownHeight + 'px') : null;
this.setState({
dropdownWidth: width,
dropdownHeight: height,
containerVisible: true
});
// use the .position() function from jquery.ui if available (requires both jquery and jquery-ui)
const hasJQueryUI = (WINDOW.jQuery && WINDOW.jQuery.ui);
const positionDropdownUsingJQuery = options.positionUsingJQuery && hasJQueryUI;
this.setState({ 'positionDropdownUsingJQuery': positionDropdownUsingJQuery });
if (!positionDropdownUsingJQuery) {
this._positionUsingDomAPI();
}
}
_positionUsingJQuery() {
const $ = WINDOW.jQuery;
const options = this.props;
const defaultPosition = {
my: 'left top',
at: 'left bottom',
of: $(this._target),
collision: 'none flip'
};
const position = _.extend({}, defaultPosition, options.positionUsing);
$(this._container)
.css('visibility', 'hidden') // jquery.ui position() requires the container to be visible to calculate its position.
.position(position)
.css('visibility', 'visible');
}
_positionUsingDomAPI() {
const scrollTop = WINDOW.document.body.scrollTop || WINDOW.document.documentElement.scrollTop || WINDOW.pageYOffset;
const scrollLeft = WINDOW.document.body.scrollLeft || WINDOW.document.documentElement.scrollLeft || WINDOW.pageXOffset;
const rect = this._target.getBoundingClientRect();
const position = {
left: (rect.left + scrollLeft) + 'px',
top: (rect.top + rect.height + scrollTop) + 'px'
};
this.setState({ containerPosition: position });
}
_updateTarget(item) {
if (item) {
this._target.value = item.value;
}
}
_hideDropdown() {
if (this.props.isInline || !this.state.containerVisible) {
return;
}
this.setState({
containerVisible: false,
containerVisiblity: null
});
this._empty();
this._reset();
// callback
this._safeCallback(this.props.dropdownHidden);
}
/**
* @param {QueryArgs} queryArgs
* @param {Array} data
* @returns {boolean}
*/
_shouldHideDropdown(queryArgs, data) {
// do not hide the dropdown if the no match item is enabled
// because the no match item is rendered within the dropdown container
if (this.props.noMatchItemEnabled) {
return false;
}
// do we have data to render?
if (!_.isEmpty(data)) {
return false;
}
// if paging is enabled hide the dropdown only when rendering the first page
if (this.props.pagingEnabled) {
return (queryArgs.paging.pageIndex === 0);
}
return true;
}
/**
* @param {QueryArgs} queryArgs
* @returns {boolean}
*/
_didQueryIdChange(queryArgs) {
return (queryArgs.queryId !== this._queryCounter);
}
/**
* @param {QueryArgs} queryArgs
* @param {Array} data
* @returns {Promise}
*/
_renderList(queryArgs, data) {
if (_.isEmpty(data)) {
return Promise.resolve();
}
return this._getRenderItemFn().then(renderItemFn => {
this.setState({
renderItems: this._getRenderItems(renderItemFn, data, queryArgs)
});
});
}
/**
* @param {QueryArgs} queryArgs
* @param {Array} data
* @returns {Promise}
*/
_renderPagedList(queryArgs, data) {
if (_.isEmpty(data)) {
return Promise.resolve();
}
return this._getRenderItemFn().then(renderItemFn => {
const items = this._getRenderItems(renderItemFn, data, queryArgs);
this._currentPageIndex = queryArgs.paging.pageIndex;
this._endOfPagedList = (items.length < this.props.pageSize);
// in case of paged list we add to the array instead of replacing it
this.setState({
renderItems: [...this.state.renderItems, ...items]
});
});
}
/**
* @param {function(RenderItemArgs): Item} renderItemFn
* @param {Array} data
* @param {QueryArgs} queryArgs
* @returns {Array.<Item>}
*/
_getRenderItems(renderItemFn, data, queryArgs) {
// limit number of items rendered in the dropdown
const dataItemsToRender = _.slice(data, 0, this.props.maxItemsToRender);
var itemsToRender = dataItemsToRender.map((data, index) => {
// invoke render callback
// this should return an object with 'label' and 'value' properties where
// 'label' is for display and 'value' is the text for the textbox
// If the object has an 'id' property, it will be used as the 'key' in the dropdown list
/** @type {RenderItemArgs} */
const renderItemArgs = {
data: data,
index: index,
searchText: queryArgs.searchText
};
const item = renderItemFn(renderItemArgs);
if (!item || !item.hasOwnProperty('label') || !item.hasOwnProperty('value')) {
return null;
}
// store the data on the item itself
item.data = data;
// unique 'id' for use in the 'key' in the dropdown list
item.id = item.hasOwnProperty('id') ? item.id : (item.value + item.label + index);
return item;
});
return _.filter(itemsToRender, function (item) {
return (item !== null);
});
}
/**
* @returns {Promise.<function(RenderItemArgs): Item>}
*/
_getRenderItemFn() {
// user provided function
const renderItemFn = this.props.renderItem;
if (_.isFunction(renderItemFn) && renderItemFn !== Fn.noop) {
return Promise.resolve(renderItemFn.bind(null));
}
// default
return Promise.resolve(this._renderItemFn.bind(null));
}
/**
* @param {RenderItemArgs} args
* @returns {Item}
*/
_renderItemFn(args) {
const data = args.data;
const value = (_.isObject(data) && this.props.selectedTextAttr) ? data[this.props.selectedTextAttr] : data;
return (
{
value: value,
label: <div>{data}</div>
}
);
}
/**
* @returns {boolean}
*/
_shouldLoadNextPage() {
return this.props.pagingEnabled
&& !this.state.dataLoadInProgress
&& !this._endOfPagedList;
}
/**
* @param {number} itemIndex
* @returns {boolean}
*/
_shouldLoadNextPageAtItemIndex(itemIndex) {
if (!this._shouldLoadNextPage()) {
return false;
}
const triggerIndex = this.state.renderItems.length - this.props.invokePageLoadWhenItemsRemaining - 1;
return itemIndex >= triggerIndex;
}
_reset() {
this._originalSearchText = null;
this._currentPageIndex = 0;
this._endOfPagedList = false;
}
}
const Fn = { noop: () => { } };
AutoComplete.defaultProps = {
/**
* CSS class applied to the dropdown container.
* @default null
*/
containerCssClass: null,
/**
* CSS class applied to the selected list element.
* @default auto-complete-item-selected
*/
selectedCssClass: 'auto-complete-item-selected',
/**
* Minimum number of characters required to display the dropdown.
* @default 1
*/
minimumChars: 1,
/**
* Maximum number of items to render in the list.
* @default 20
*/
maxItemsToRender: 20,
/**
* If true displays the dropdown list when the textbox gets focus.
* @default false
*/
activateOnFocus: false,
/**
* Width in "px" of the dropddown list. This can also be applied using CSS.
* @default null
*/
dropdownWidth: null,
/**
* Maximum height in "px" of the dropddown list. This can also be applied using CSS.
* @default null
*/
dropdownHeight: null,
/**
* Set to true to display the dropdown list inline.
* @default false
*/
isInline: false,
/**
* If the data for the dropdown is a collection of objects, this should be the name
* of a property on the object. The property value will be used to update the input textbox.
* @default null
*/
selectedTextAttr: null,
/**
* Set to true to enable server side paging. See "data" callback for more information.
* @default false
*/
pagingEnabled: false,
/**
* The number of items to display per page when paging is enabled.
* @default 5
*/
pageSize: 5,
/**
* When using the keyboard arrow key to scroll down the list, the "data" callback will
* be invoked when at least this many items remain below the current focused item.
* Note that dragging the vertical scrollbar to the bottom of the list might also invoke a "data" callback.
* @default 1
*/
invokePageLoadWhenItemsRemaining: 1,
/**
* Set to true to position the dropdown list using the position() method from the jQueryUI library.
* See <a href="https://api.jqueryui.com/position/">jQueryUI.position() documentation</a>
* @default true
* @bindAsHtml true
*/
positionUsingJQuery: true,
/**
* Options that will be passed to jQueryUI position() method.
* @default null
*/
positionUsing: null,
/**
* Set to true to let the plugin hide the dropdown list. If this option is set to false you can hide the dropdown list
* with the hideDropdown() method available in the ready callback.
* @default true
*/
autoHideDropdown: true,
/**
* Set to true to hide the dropdown list when the window is resized. If this option is set to false you can hide
* or re-position the dropdown list with the hideDropdown() or positionDropdown() methods available in the ready.
* callback.
* @default true
*/
hideDropdownOnWindowResize: true,
/**
* Set to true to display a message when no items match the search text.
* @default true
*/
noMatchItemEnabled: true,
/**
* Callback for custom rendering of the message when no items match the search text. The callback receives an object
* with a "searchText" property. This function must return a JSX.
* If a callback is not provided, the default JSX used is <span>No results match '{searchText}'></span>
* @default noop
*/
renderNoMatchItem: Fn.noop,
/**
* Callback after the plugin is initialized and ready.
* @default noop
*/
ready: Fn.noop,
/**
* Callback before the "data" callback is invoked.
* @default noop
*/
loading: Fn.noop,
/**
* Callback to get the data for the dropdown. The callback receives the search text as the first parameter.
* If paging is enabled the callback receives an object with "pageIndex" and "pageSize" properties as the second parameter.
* This function must return a promise.
* @default noop
*/
data: Fn.noop,
/**
* Callback after the items are rendered in the dropdown
* @default noop
*/
loadingComplete: Fn.noop,
/**
* Callback for custom rendering a list item. This is called for each item in the dropdown.
* This must return an object with "value" and "label" properties where "label" is the JSX
* for display and "value" is the text for the textbox. If the object has an "id" property,
* it will be used as the "key" when rendering the dropdown list.
* @default noop
*/
renderItem: Fn.noop,
/**
* Callback after an item is selected from the dropdown. The callback receives an object with an "item" property representing the selected item.
* @default noop
*/
itemSelected: Fn.noop,
/**
* Callback after the dropdown is shown.
* @default noop
*/
dropdownShown: Fn.noop,
/**
* Callback after the dropdown is hidden.
* @default noop
*/
dropdownHidden: Fn.noop
};
AutoComplete.propTypes = {
containerCssClass: PropTypes.string,
selectedCssClass: PropTypes.string,
minimumChars: PropTypes.number,
maxItemsToRender: PropTypes.number,
activateOnFocus: PropTypes.bool,
dropdownWidth: PropTypes.number,
dropdownHeight: PropTypes.number,
isInline: PropTypes.bool,
selectedTextAttr: PropTypes.string,
pagingEnabled: PropTypes.bool,
pageSize: PropTypes.number,
invokePageLoadWhenItemsRemaining: PropTypes.number,
positionUsingJQuery: PropTypes.bool,
positionUsing: PropTypes.shape({
my: PropTypes.string,
at: PropTypes.string,
of: PropTypes.any,
collision: PropTypes.string
}),
autoHideDropdown: PropTypes.bool,
hideDropdownOnWindowResize: PropTypes.bool,
noMatchItemEnabled: PropTypes.bool.isRequired,
renderNoMatchItem: PropTypes.func,
ready: PropTypes.func,
loading: PropTypes.func,
data: PropTypes.func.isRequired,
loadingComplete: PropTypes.func,
renderItem: PropTypes.func,
itemSelected: PropTypes.func,
dropdownShown: PropTypes.func,
dropdownHidden: PropTypes.func
};
// <ul> element
class AutoCompleteList extends Component {
constructor(props) {
super(props);
this.state = {};
this.scrollToItem = this.scrollToItem.bind(this);
}
componentDidMount() {
this._subscribeDOMEvents();
}
componentWillUnmount() {
this._unsubscribeDOMEvents();
}
/**
* @param {Object} nextProps
*/
componentWillReceiveProps(nextProps) {
this._setNoMatchItemIfEmpty(nextProps);
}
render() {
var style = {
maxHeight: this.props.dropdownHeight
};
return (
<ul
ref={x => this._elementUL = x}
style={style}
className="auto-complete-results"
>
{this._renderListItems()}
</ul>
);
}
/**
* @param {itemIndex} number
*/
scrollToItem(itemIndex) {
var attrSelector = 'li[data-index="' + itemIndex + '"]';
// use jquery.scrollTo plugin if available
// http://flesler.blogspot.com/2007/10/jqueryscrollto.html
if (WINDOW.jQuery && WINDOW.jQuery.scrollTo) {
const $elementUL = WINDOW.jQuery(this._elementUL);
$elementUL.scrollTo($elementUL.find(attrSelector));
return;
}
var li = this._elementUL.querySelector(attrSelector);
if (li) {
// this was causing the page to jump/scroll
// li.scrollIntoView(true);
this._elementUL.scrollTop = li.offsetTop;
}
}
/**
* @returns {JSX.Element}
*/
_renderListItems() {
if (!_.isEmpty(this.props.items)) {
return this._createDataItems();
}
if (this.props.noMatchItemEnabled) {
return this._createNoMatchItem();
}
return null;
}
/**
* @returns {JSX.Element}
*/
_createDataItems() {
var selectedCssClass = this._getSelectedCssClass();
return this.props.items.map((item, index) => {
var classNames = classnames(
'auto-complete-item',
{ [selectedCssClass]: (index === this.props.selectedIndex) }
);
return (
<li key={item.id}
onClick={() => this.props.onItemClick(index, true)}
className={classNames}
data-index={index}
>
{item.label}
</li>
);
});
}
/**
* @returns {JSX.Element}
*/
_createNoMatchItem() {
return (
<li className="auto-complete-item auto-complete-no-match">
{this.state.noMatchItem}
</li>
);
}
/**
* @param {Object} nextProps
*/
_setNoMatchItemIfEmpty(nextProps) {
if (!_.isEmpty(nextProps.items) || !nextProps.noMatchItemEnabled) {
return;
}
this._getRenderNoMatchItemFn().then(renderItemFn => {
this.setState({
noMatchItem: renderItemFn({ searchText: this.props.searchText })
});
});
}
/**
* @returns {Promise}
*/
_getRenderNoMatchItemFn() {
// user provided function
const renderNoMatchItem = this.props.renderNoMatchItem;
if (_.isFunction(renderNoMatchItem) && renderNoMatchItem !== Fn.noop) {
return Promise.resolve(renderNoMatchItem.bind(null));
}
// default
return Promise.resolve(this._renderNoMatchItem.bind(null));
}
/**
* @param {RenderNoMatchItemArgs} args
* @returns {JSX.Element}
*/
_renderNoMatchItem(args) {
return (
<span>No results match '{args.searchText}'</span>
);
}
/**
* @returns {string}
*/
_getSelectedCssClass() {
var selectedIndex = this.props.selectedIndex;
if (selectedIndex === -1) {
return '';
}
return this.props.getSelectedCssClass(selectedIndex);
}
_subscribeDOMEvents() {
this._elementUL.addEventListener(DOM_EVENT.SCROLL, this.props.onScroll);
}
_unsubscribeDOMEvents() {
this._elementUL.removeEventListener(DOM_EVENT.SCROLL, this.props.onScroll);
}
}
AutoCompleteList.propTypes = {
items: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.oneOfType([PropTypes.string, PropTypes.any]),
label: PropTypes.oneOfType([PropTypes.string, PropTypes.any])
})).isRequired,
searchText: PropTypes.string,
selectedIndex: PropTypes.number.isRequired,
dropdownHeight: PropTypes.string,
noMatchItemEnabled: PropTypes.bool.isRequired,
renderNoMatchItem: PropTypes.func,
getSelectedCssClass: PropTypes.func.isRequired,
onItemClick: PropTypes.func.isRequired,
onScroll: PropTypes.func.isRequired
};
function HelperService() {
var components = [];
var instanceCount = 0;
var activeInstanceId = 0;
/**
* @param {AutoComplete} component
* @returns {number}
*/
this.registerComponent = component => {
if (component) {
components.push(component);
return ++instanceCount;
}
return -1;
};
/**
* @param {number} instanceId
*/
this.setActiveInstanceId = instanceId => {
activeInstanceId = instanceId;
this.hideAllInactive();
};
this.hideAllInactive = () => {
components.forEach(component => {
// hide if this is not the active instance
if (component._instanceId !== activeInstanceId) {
component._autoHide();
}
});
};
}
const DOM_EVENT = {
RESIZE: 'resize',
SCROLL: 'scroll',
CLICK: 'click',
KEYDOWN: 'keydown',
FOCUS: 'focus',
INPUT: 'input'
};
const KEYCODE = {
TAB: 9,
ENTER: 13,
CTRL: 17,
ALT: 18,
ESCAPE: 27,
LEFTARROW: 37,
UPARROW: 38,
RIGHTARROW: 39,
DOWNARROW: 40,
MAC_COMMAND_LEFT: 91,
MAC_COMMAND_RIGHT: 93
};
function ignoreKeyCode(keyCode) {
return [
KEYCODE.TAB,
KEYCODE.ALT,
KEYCODE.CTRL,
KEYCODE.LEFTARROW,
KEYCODE.RIGHTARROW,
KEYCODE.MAC_COMMAND_LEFT,
KEYCODE.MAC_COMMAND_RIGHT
].indexOf(keyCode) !== -1;
}
/**
* @typedef {Object} Item
* @property {string} value
* @property {Object} label
* @property {string} id
*/
/**
* @typedef {Object} RenderItemArgs
* @property {Object} data
* @property {number} index
* @property {string} searchText
*/
/**
* @typedef {Object} RenderNoMatchItemArgs
* @property {string} searchText
*/
/**
* @typedef {Object} PagingArgs
* @property {number} pageIndex
* @property {number} pageSize
*/
/**
* @typedef {Object} QueryArgs
* @property {string} searchText
* @property {PagingArgs} paging
* @property {number} queryId
*/