/* YUI 3.17.2 (build 9c3c78e) Copyright 2014 Yahoo! Inc. All rights reserved. Licensed under the BSD License. http://yuilibrary.com/license/ */ YUI.add('autocomplete-base', function (Y, NAME) { /** Provides automatic input completion or suggestions for text input fields and textareas. @module autocomplete @main autocomplete @since 3.3.0 **/ /** `Y.Base` extension that provides core autocomplete logic (but no UI implementation) for a text input field or textarea. Must be mixed into a `Y.Base`-derived class to be useful. @module autocomplete @submodule autocomplete-base **/ /** Extension that provides core autocomplete logic (but no UI implementation) for a text input field or textarea. The `AutoCompleteBase` class provides events and attributes that abstract away core autocomplete logic and configuration, but does not provide a widget implementation or suggestion UI. For a prepackaged autocomplete widget, see `AutoCompleteList`. This extension cannot be instantiated directly, since it doesn't provide an actual implementation. It's intended to be mixed into a `Y.Base`-based class or widget. `Y.Widget`-based example: YUI().use('autocomplete-base', 'widget', function (Y) { var MyAC = Y.Base.create('myAC', Y.Widget, [Y.AutoCompleteBase], { // Custom prototype methods and properties. }, { // Custom static methods and properties. }); // Custom implementation code. }); `Y.Base`-based example: YUI().use('autocomplete-base', function (Y) { var MyAC = Y.Base.create('myAC', Y.Base, [Y.AutoCompleteBase], { initializer: function () { this._bindUIACBase(); this._syncUIACBase(); }, // Custom prototype methods and properties. }, { // Custom static methods and properties. }); // Custom implementation code. }); @class AutoCompleteBase **/ var Escape = Y.Escape, Lang = Y.Lang, YArray = Y.Array, YObject = Y.Object, isFunction = Lang.isFunction, isString = Lang.isString, trim = Lang.trim, INVALID_VALUE = Y.Attribute.INVALID_VALUE, _FUNCTION_VALIDATOR = '_functionValidator', _SOURCE_SUCCESS = '_sourceSuccess', ALLOW_BROWSER_AC = 'allowBrowserAutocomplete', INPUT_NODE = 'inputNode', QUERY = 'query', QUERY_DELIMITER = 'queryDelimiter', REQUEST_TEMPLATE = 'requestTemplate', RESULTS = 'results', RESULT_LIST_LOCATOR = 'resultListLocator', VALUE = 'value', VALUE_CHANGE = 'valueChange', EVT_CLEAR = 'clear', EVT_QUERY = QUERY, EVT_RESULTS = RESULTS; function AutoCompleteBase() {} AutoCompleteBase.prototype = { // -- Lifecycle Methods ---------------------------------------------------- initializer: function () { // AOP bindings. Y.before(this._bindUIACBase, this, 'bindUI'); Y.before(this._syncUIACBase, this, 'syncUI'); // -- Public Events ---------------------------------------------------- /** Fires after the query has been completely cleared or no longer meets the minimum query length requirement. @event clear @param {String} prevVal Value of the query before it was cleared. @param {String} src Source of the event. @preventable _defClearFn **/ this.publish(EVT_CLEAR, { defaultFn: this._defClearFn }); /** Fires when the contents of the input field have changed and the input value meets the criteria necessary to generate an autocomplete query. @event query @param {String} inputValue Full contents of the text input field or textarea that generated the query. @param {String} query AutoComplete query. This is the string that will be used to request completion results. It may or may not be the same as `inputValue`. @param {String} src Source of the event. @preventable _defQueryFn **/ this.publish(EVT_QUERY, { defaultFn: this._defQueryFn }); /** Fires after query results are received from the source. If no source has been set, this event will not fire. @event results @param {Array|Object} data Raw, unfiltered result data (if available). @param {String} query Query that generated these results. @param {Object[]} results Array of filtered, formatted, and highlighted results. Each item in the array is an object with the following properties: @param {Node|HTMLElement|String} results.display Formatted result HTML suitable for display to the user. If no custom formatter is set, this will be an HTML-escaped version of the string in the `text` property. @param {String} [results.highlighted] Highlighted (but not formatted) result text. This property will only be set if a highlighter is in use. @param {Any} results.raw Raw, unformatted result in whatever form it was provided by the source. @param {String} results.text Plain text version of the result, suitable for being inserted into the value of a text input field or textarea when the result is selected by a user. This value is not HTML-escaped and should not be inserted into the page using `innerHTML` or `Node#setContent()`. @preventable _defResultsFn **/ this.publish(EVT_RESULTS, { defaultFn: this._defResultsFn }); }, destructor: function () { this._acBaseEvents && this._acBaseEvents.detach(); delete this._acBaseEvents; delete this._cache; delete this._inputNode; delete this._rawSource; }, // -- Public Prototype Methods --------------------------------------------- /** Clears the result cache. @method clearCache @chainable @since 3.5.0 **/ clearCache: function () { this._cache && (this._cache = {}); return this; }, /** Sends a request to the configured source. If no source is configured, this method won't do anything. Usually there's no reason to call this method manually; it will be called automatically when user input causes a `query` event to be fired. The only time you'll need to call this method manually is if you want to force a request to be sent when no user input has occurred. @method sendRequest @param {String} [query] Query to send. If specified, the `query` attribute will be set to this query. If not specified, the current value of the `query` attribute will be used. @param {Function} [requestTemplate] Request template function. If not specified, the current value of the `requestTemplate` attribute will be used. @chainable **/ sendRequest: function (query, requestTemplate) { var request, source = this.get('source'); if (query || query === '') { this._set(QUERY, query); } else { query = this.get(QUERY) || ''; } if (source) { if (!requestTemplate) { requestTemplate = this.get(REQUEST_TEMPLATE); } request = requestTemplate ? requestTemplate.call(this, query) : query; source.sendRequest({ query : query, request: request, callback: { success: Y.bind(this._onResponse, this, query) } }); } return this; }, // -- Protected Lifecycle Methods ------------------------------------------ /** Attaches event listeners and behaviors. @method _bindUIACBase @protected **/ _bindUIACBase: function () { var inputNode = this.get(INPUT_NODE), tokenInput = inputNode && inputNode.tokenInput; // If the inputNode has a node-tokeninput plugin attached, bind to the // plugin's inputNode instead. if (tokenInput) { inputNode = tokenInput.get(INPUT_NODE); this._set('tokenInput', tokenInput); } if (!inputNode) { Y.error('No inputNode specified.'); return; } this._inputNode = inputNode; this._acBaseEvents = new Y.EventHandle([ // This is the valueChange event on the inputNode, provided by the // event-valuechange module, not our own valueChange. inputNode.on(VALUE_CHANGE, this._onInputValueChange, this), inputNode.on('blur', this._onInputBlur, this), this.after(ALLOW_BROWSER_AC + 'Change', this._syncBrowserAutocomplete), this.after('sourceTypeChange', this._afterSourceTypeChange), this.after(VALUE_CHANGE, this._afterValueChange) ]); }, /** Synchronizes the UI state of the `inputNode`. @method _syncUIACBase @protected **/ _syncUIACBase: function () { this._syncBrowserAutocomplete(); this.set(VALUE, this.get(INPUT_NODE).get(VALUE)); }, // -- Protected Prototype Methods ------------------------------------------ /** Creates a DataSource-like object that simply returns the specified array as a response. See the `source` attribute for more details. @method _createArraySource @param {Array} source @return {Object} DataSource-like object. @protected **/ _createArraySource: function (source) { var that = this; return { type: 'array', sendRequest: function (request) { that[_SOURCE_SUCCESS](source.concat(), request); } }; }, /** Creates a DataSource-like object that passes the query to a custom-defined function, which is expected to call the provided callback with an array of results. See the `source` attribute for more details. @method _createFunctionSource @param {Function} source Function that accepts a query and a callback as parameters, and calls the callback with an array of results. @return {Object} DataSource-like object. @protected **/ _createFunctionSource: function (source) { var that = this; return { type: 'function', sendRequest: function (request) { var value; function afterResults(results) { that[_SOURCE_SUCCESS](results || [], request); } // Allow both synchronous and asynchronous functions. If we get // a truthy return value, assume the function is synchronous. if ((value = source(request.query, afterResults))) { afterResults(value); } } }; }, /** Creates a DataSource-like object that looks up queries as properties on the specified object, and returns the found value (if any) as a response. See the `source` attribute for more details. @method _createObjectSource @param {Object} source @return {Object} DataSource-like object. @protected **/ _createObjectSource: function (source) { var that = this; return { type: 'object', sendRequest: function (request) { var query = request.query; that[_SOURCE_SUCCESS]( YObject.owns(source, query) ? source[query] : [], request ); } }; }, /** Returns `true` if _value_ is either a function or `null`. @method _functionValidator @param {Function|null} value Value to validate. @protected **/ _functionValidator: function (value) { return value === null || isFunction(value); }, /** Faster and safer alternative to `Y.Object.getValue()`. Doesn't bother casting the path to an array (since we already know it's an array) and doesn't throw an error if a value in the middle of the object hierarchy is neither `undefined` nor an object. @method _getObjectValue @param {Object} obj @param {Array} path @return {Any} Located value, or `undefined` if the value was not found at the specified path. @protected **/ _getObjectValue: function (obj, path) { if (!obj) { return; } for (var i = 0, len = path.length; obj && i < len; i++) { obj = obj[path[i]]; } return obj; }, /** Parses result responses, performs filtering and highlighting, and fires the `results` event. @method _parseResponse @param {String} query Query that generated these results. @param {Object} response Response containing results. @param {Object} data Raw response data. @protected **/ _parseResponse: function (query, response, data) { var facade = { data : data, query : query, results: [] }, listLocator = this.get(RESULT_LIST_LOCATOR), results = [], unfiltered = response && response.results, filters, formatted, formatter, highlighted, highlighter, i, len, maxResults, result, text, textLocator; if (unfiltered && listLocator) { unfiltered = listLocator.call(this, unfiltered); } if (unfiltered && unfiltered.length) { filters = this.get('resultFilters'); textLocator = this.get('resultTextLocator'); // Create a lightweight result object for each result to make them // easier to work with. The various properties on the object // represent different formats of the result, and will be populated // as we go. for (i = 0, len = unfiltered.length; i < len; ++i) { result = unfiltered[i]; text = textLocator ? textLocator.call(this, result) : result.toString(); results.push({ display: Escape.html(text), raw : result, text : text }); } // Run the results through all configured result filters. Each // filter returns an array of (potentially fewer) result objects, // which is then passed to the next filter, and so on. for (i = 0, len = filters.length; i < len; ++i) { results = filters[i].call(this, query, results.concat()); if (!results) { return; } if (!results.length) { break; } } if (results.length) { formatter = this.get('resultFormatter'); highlighter = this.get('resultHighlighter'); maxResults = this.get('maxResults'); // If maxResults is set and greater than 0, limit the number of // results. if (maxResults && maxResults > 0 && results.length > maxResults) { results.length = maxResults; } // Run the results through the configured highlighter (if any). // The highlighter returns an array of highlighted strings (not // an array of result objects), and these strings are then added // to each result object. if (highlighter) { highlighted = highlighter.call(this, query, results.concat()); if (!highlighted) { return; } for (i = 0, len = highlighted.length; i < len; ++i) { result = results[i]; result.highlighted = highlighted[i]; result.display = result.highlighted; } } // Run the results through the configured formatter (if any) to // produce the final formatted results. The formatter returns an // array of strings or Node instances (not an array of result // objects), and these strings/Nodes are then added to each // result object. if (formatter) { formatted = formatter.call(this, query, results.concat()); if (!formatted) { return; } for (i = 0, len = formatted.length; i < len; ++i) { results[i].display = formatted[i]; } } } } facade.results = results; this.fire(EVT_RESULTS, facade); }, /** Returns the query portion of the specified input value, or `null` if there is no suitable query within the input value. If a query delimiter is defined, the query will be the last delimited part of of the string. @method _parseValue @param {String} value Input value from which to extract the query. @return {String|null} query @protected **/ _parseValue: function (value) { var delim = this.get(QUERY_DELIMITER); if (delim) { value = value.split(delim); value = value[value.length - 1]; } return Lang.trimLeft(value); }, /** Setter for the `enableCache` attribute. @method _setEnableCache @param {Boolean} value @protected @since 3.5.0 **/ _setEnableCache: function (value) { // When `this._cache` is an object, result sources will store cached // results in it. When it's falsy, they won't. This way result sources // don't need to get the value of the `enableCache` attribute on every // request, which would be sloooow. this._cache = value ? {} : null; }, /** Setter for locator attributes. @method _setLocator @param {Function|String|null} locator @return {Function|null} @protected **/ _setLocator: function (locator) { if (this[_FUNCTION_VALIDATOR](locator)) { return locator; } var that = this; locator = locator.toString().split('.'); return function (result) { return result && that._getObjectValue(result, locator); }; }, /** Setter for the `requestTemplate` attribute. @method _setRequestTemplate @param {Function|String|null} template @return {Function|null} @protected **/ _setRequestTemplate: function (template) { if (this[_FUNCTION_VALIDATOR](template)) { return template; } template = template.toString(); return function (query) { return Lang.sub(template, {query: encodeURIComponent(query)}); }; }, /** Setter for the `resultFilters` attribute. @method _setResultFilters @param {Array|Function|String|null} filters `null`, a filter function, an array of filter functions, or a string or array of strings representing the names of methods on `Y.AutoCompleteFilters`. @return {Function[]} Array of filter functions (empty if filters is `null`). @protected **/ _setResultFilters: function (filters) { var acFilters, getFilterFunction; if (filters === null) { return []; } acFilters = Y.AutoCompleteFilters; getFilterFunction = function (filter) { if (isFunction(filter)) { return filter; } if (isString(filter) && acFilters && isFunction(acFilters[filter])) { return acFilters[filter]; } return false; }; if (Lang.isArray(filters)) { filters = YArray.map(filters, getFilterFunction); return YArray.every(filters, function (f) { return !!f; }) ? filters : INVALID_VALUE; } else { filters = getFilterFunction(filters); return filters ? [filters] : INVALID_VALUE; } }, /** Setter for the `resultHighlighter` attribute. @method _setResultHighlighter @param {Function|String|null} highlighter `null`, a highlighter function, or a string representing the name of a method on `Y.AutoCompleteHighlighters`. @return {Function|null} @protected **/ _setResultHighlighter: function (highlighter) { var acHighlighters; if (this[_FUNCTION_VALIDATOR](highlighter)) { return highlighter; } acHighlighters = Y.AutoCompleteHighlighters; if (isString(highlighter) && acHighlighters && isFunction(acHighlighters[highlighter])) { return acHighlighters[highlighter]; } return INVALID_VALUE; }, /** Setter for the `source` attribute. Returns a DataSource or a DataSource-like object depending on the type of _source_ and/or the value of the `sourceType` attribute. @method _setSource @param {Any} source AutoComplete source. See the `source` attribute for details. @return {DataSource|Object} @protected **/ _setSource: function (source) { var sourceType = this.get('sourceType') || Lang.type(source), sourceSetter; if ((source && isFunction(source.sendRequest)) || source === null || sourceType === 'datasource') { // Quacks like a DataSource instance (or null). Make it so! this._rawSource = source; return source; } // See if there's a registered setter for this source type. if ((sourceSetter = AutoCompleteBase.SOURCE_TYPES[sourceType])) { this._rawSource = source; return Lang.isString(sourceSetter) ? this[sourceSetter](source) : sourceSetter(source); } Y.error("Unsupported source type '" + sourceType + "'. Maybe autocomplete-sources isn't loaded?"); return INVALID_VALUE; }, /** Shared success callback for non-DataSource sources. @method _sourceSuccess @param {Any} data Response data. @param {Object} request Request object. @protected **/ _sourceSuccess: function (data, request) { request.callback.success({ data: data, response: {results: data} }); }, /** Synchronizes the UI state of the `allowBrowserAutocomplete` attribute. @method _syncBrowserAutocomplete @protected **/ _syncBrowserAutocomplete: function () { var inputNode = this.get(INPUT_NODE); if (inputNode.get('nodeName').toLowerCase() === 'input') { inputNode.setAttribute('autocomplete', this.get(ALLOW_BROWSER_AC) ? 'on' : 'off'); } }, /** Updates the query portion of the `value` attribute. If a query delimiter is defined, the last delimited portion of the input value will be replaced with the specified _value_. @method _updateValue @param {String} newVal New value. @protected **/ _updateValue: function (newVal) { var delim = this.get(QUERY_DELIMITER), insertDelim, len, prevVal; newVal = Lang.trimLeft(newVal); if (delim) { insertDelim = trim(delim); // so we don't double up on spaces prevVal = YArray.map(trim(this.get(VALUE)).split(delim), trim); len = prevVal.length; if (len > 1) { prevVal[len - 1] = newVal; newVal = prevVal.join(insertDelim + ' '); } newVal = newVal + insertDelim + ' '; } this.set(VALUE, newVal); }, // -- Protected Event Handlers --------------------------------------------- /** Updates the current `source` based on the new `sourceType` to ensure that the two attributes don't get out of sync when they're changed separately. @method _afterSourceTypeChange @param {EventFacade} e @protected **/ _afterSourceTypeChange: function (e) { if (this._rawSource) { this.set('source', this._rawSource); } }, /** Handles change events for the `value` attribute. @method _afterValueChange @param {EventFacade} e @protected **/ _afterValueChange: function (e) { var newVal = e.newVal, self = this, uiChange = e.src === AutoCompleteBase.UI_SRC, delay, fire, minQueryLength, query; // Update the UI if the value was changed programmatically. if (!uiChange) { self._inputNode.set(VALUE, newVal); } minQueryLength = self.get('minQueryLength'); query = self._parseValue(newVal) || ''; if (minQueryLength >= 0 && query.length >= minQueryLength) { // Only query on changes that originate from the UI. if (uiChange) { delay = self.get('queryDelay'); fire = function () { self.fire(EVT_QUERY, { inputValue: newVal, query : query, src : e.src }); }; if (delay) { clearTimeout(self._delay); self._delay = setTimeout(fire, delay); } else { fire(); } } else { // For programmatic value changes, just update the query // attribute without sending a query. self._set(QUERY, query); } } else { clearTimeout(self._delay); self.fire(EVT_CLEAR, { prevVal: e.prevVal ? self._parseValue(e.prevVal) : null, src : e.src }); } }, /** Handles `blur` events on the input node. @method _onInputBlur @param {EventFacade} e @protected **/ _onInputBlur: function (e) { var delim = this.get(QUERY_DELIMITER), delimPos, newVal, value; // If a query delimiter is set and the input's value contains one or // more trailing delimiters, strip them. if (delim && !this.get('allowTrailingDelimiter')) { delim = Lang.trimRight(delim); value = newVal = this._inputNode.get(VALUE); if (delim) { while ((newVal = Lang.trimRight(newVal)) && (delimPos = newVal.length - delim.length) && newVal.lastIndexOf(delim) === delimPos) { newVal = newVal.substring(0, delimPos); } } else { // Delimiter is one or more space characters, so just trim the // value. newVal = Lang.trimRight(newVal); } if (newVal !== value) { this.set(VALUE, newVal); } } }, /** Handles `valueChange` events on the input node and fires a `query` event when the input value meets the configured criteria. @method _onInputValueChange @param {EventFacade} e @protected **/ _onInputValueChange: function (e) { var newVal = e.newVal; // Don't query if the internal value is the same as the new value // reported by valueChange. if (newVal !== this.get(VALUE)) { this.set(VALUE, newVal, {src: AutoCompleteBase.UI_SRC}); } }, /** Handles source responses and fires the `results` event. @method _onResponse @param {EventFacade} e @protected **/ _onResponse: function (query, e) { // Ignore stale responses that aren't for the current query. if (query === (this.get(QUERY) || '')) { this._parseResponse(query || '', e.response, e.data); } }, // -- Protected Default Event Handlers ------------------------------------- /** Default `clear` event handler. Sets the `results` attribute to an empty array and `query` to null. @method _defClearFn @protected **/ _defClearFn: function () { this._set(QUERY, null); this._set(RESULTS, []); }, /** Default `query` event handler. Sets the `query` attribute and sends a request to the source if one is configured. @method _defQueryFn @param {EventFacade} e @protected **/ _defQueryFn: function (e) { this.sendRequest(e.query); // sendRequest will set the 'query' attribute }, /** Default `results` event handler. Sets the `results` attribute to the latest results. @method _defResultsFn @param {EventFacade} e @protected **/ _defResultsFn: function (e) { this._set(RESULTS, e[RESULTS]); } }; AutoCompleteBase.ATTRS = { /** Whether or not to enable the browser's built-in autocomplete functionality for input fields. @attribute allowBrowserAutocomplete @type Boolean @default false **/ allowBrowserAutocomplete: { value: false }, /** When a `queryDelimiter` is set, trailing delimiters will automatically be stripped from the input value by default when the input node loses focus. Set this to `true` to allow trailing delimiters. @attribute allowTrailingDelimiter @type Boolean @default false **/ allowTrailingDelimiter: { value: false }, /** Whether or not to enable in-memory caching in result sources that support it. @attribute enableCache @type Boolean @default true @since 3.5.0 **/ enableCache: { lazyAdd: false, // we need the setter to run on init setter: '_setEnableCache', value: true }, /** Node to monitor for changes, which will generate `query` events when appropriate. May be either an `` or a `