adobe/brackets

View on GitHub
src/search/QuickSearchField.js

Summary

Maintainability
B
4 hrs
Test Coverage
/*
 * Copyright (c) 2013 - present Adobe Systems Incorporated. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 *
 */

/*
 * Text field with attached dropdown list that is updated (based on a provider) whenever the text changes.
 *
 * For styling, the DOM structure of the popup is as follows:
 *  body
 *      ol.quick-search-container
 *          li
 *          li.highlight
 *          li
 * And the text field is:
 *      input
 *      input.no-results
 */
define(function (require, exports, module) {
    "use strict";

    var KeyEvent = require("utils/KeyEvent");


    /**
     * Attaches to an existing <input> tag
     *
     * @constructor
     *
     * @param {!jQueryObject} $input
     * @param {!function(string):($.Promise|Array.<*>|{error:?string}} options.resultProvider
     *          Given the current search text, returns an an array of result objects, an error object, or a
     *          Promise that yields one of those. If the Promise is still outstanding when the query next
     *          changes, resultProvider() will be called again (without waiting for the earlier Promise), and
     *          the Promise's result will be ignored.
     *          If the provider yields [], or a non-null error string, input is decorated with ".no-results"; if
     *          the provider yields a null error string, input is not decorated.
     *
     * @param {!function(*, string):string} options.formatter
     *          Converts one result object to a string of HTML text. Passed the item and the current query. The
     *          outermost element must be <li>. The ".highlight" class can be ignored as it is applied automatically.
     * @param {!function(?*, string):void} options.onCommit
     *          Called when an item is selected by clicking or pressing Enter. Passed the item and the current
     *          query. If the current result list is not up to date with the query text at the time Enter is
     *          pressed, waits until it is before running this callback. If Enter pressed with no results, passed
     *          null. The popup remains open after this event.
     * @param {!function(*, string, boolean):void} options.onHighlight
     *          Called when an item is highlighted in the list. Passed the item, the current query, and a flag that is
     *          true if the item was highlighted explicitly (arrow keys), not simply due to a results list update. Since
     *          the top item in the list is always initially highlighted, every time the list is updated onHighlight()
     *          is called with the top item and with the explicit flag set to false.
     * @param {?number} options.maxResults
     *          Maximum number of items from resultProvider() to display in the popup.
     * @param {?number} options.verticalAdjust
     *          Number of pixels to position the popup below where $input is when constructor is called. Useful
     *          if UI is going to animate position after construction, but QuickSearchField may receive input
     *          before the animation is done.
     * @param {?number} options.firstHighlightIndex
     *          Index of the result that is highlighted by default. null to not highlight any result.
     */
    function QuickSearchField($input, options) {
        this.$input = $input;
        this.options = options;

        options.maxResults = options.maxResults || 10;

        this._handleInput   = this._handleInput.bind(this);
        this._handleKeyDown = this._handleKeyDown.bind(this);

        if (options.highlightZeroResults !== undefined) {
            this._highlightZeroResults = options.highlightZeroResults;
        } else {
            this._highlightZeroResults = true;
        }

        $input.on("input", this._handleInput);
        $input.on("keydown", this._handleKeyDown);
        
        // For search History this value is set to null
        this._firstHighlightIndex = options.firstHighlightIndex;

        this._dropdownTop = $input.offset().top + $input.height() + (options.verticalAdjust || 0);
    }

    /** @type {!Object} */
    QuickSearchField.prototype.options = null;

    /** @type {?$.Promise} Promise corresponding to latest resultProvider call. Any earlier promises ignored */
    QuickSearchField.prototype._pending = null;

    /** @type {boolean} True if Enter already pressed & just waiting for results to arrive before committing */
    QuickSearchField.prototype._commitPending = false;

    /** @type {?string} Value of $input corresponding to the _displayedResults list */
    QuickSearchField.prototype._displayedQuery = null;

    /** @type {?Array.<*>}  Latest resultProvider result */
    QuickSearchField.prototype._displayedResults = null;

    /** @type {?number} */
    QuickSearchField.prototype._highlightIndex = null;

    /** @type {?jQueryObject} Dropdown's <ol>, while open; null while closed */
    QuickSearchField.prototype._$dropdown = null;

    /** @type {!jQueryObject} */
    QuickSearchField.prototype.$input = null;


    /** When text field changes, update results list */
    QuickSearchField.prototype._handleInput = function () {
        this._pending = null;  // immediately invalidate any previous Promise

        var valueAtEvent = this.$input.val();
        var self = this;
        // The timeout lets us skip over a backlog of multiple keyboard events when the provider is responding
        // so slowly that JS execution can't keep up. All the remaining input events are serviced before the
        // first timeout runs; then all the queued-up timeouts run in a row. All except the last one can no-op.
        setTimeout(function () {
            if (self.$input.val() === valueAtEvent) {
                self.updateResults();
            }
        }, 0);
    };

    /** Handle special keys: Enter, Up/Down */
    QuickSearchField.prototype._handleKeyDown = function (event) {
        if (event.keyCode === KeyEvent.DOM_VK_RETURN) {
            // Enter should always act on the latest results. If input has changed and we're still waiting for
            // new results, just flag the 'commit' for later
            if (this._displayedQuery === this.$input.val()) {
                event.preventDefault();  // prevents keyup from going to someone else after we close
                this._doCommit();
            } else {
                // Once the current wait resolves, _render() will run the commit
                this._commitPending = true;
            }
        } else if (event.keyCode === KeyEvent.DOM_VK_DOWN) {
            // Highlight changes are always done synchronously on the currently shown result list. If the list
            // later changes, the highlight is reset to the top
            if (this._displayedResults && this._displayedResults.length) {
                if (this._highlightIndex === null || this._highlightIndex === this._displayedResults.length - 1) {
                    this._highlightIndex = 0;
                } else {
                    this._highlightIndex++;
                }
                this._updateHighlight(true);
            }
            event.preventDefault(); // treated as Home key otherwise

        } else if (event.keyCode === KeyEvent.DOM_VK_UP) {
            if (this._displayedResults && this._displayedResults.length) {
                if (this._highlightIndex === null || this._highlightIndex === 0) {
                    this._highlightIndex = this._displayedResults.length - 1;
                } else {
                    this._highlightIndex--;
                }
                this._updateHighlight(true);
            }
            event.preventDefault(); // treated as End key otherwise
        }
    };

    /** Call onCommit() immediately */
    QuickSearchField.prototype._doCommit = function (index) {
        var item;
        if (this._displayedResults && this._displayedResults.length) {
            if (index >= 0) {
                item = this._displayedResults[index];
            } else if (this._highlightIndex >= 0) {
                item = this._displayedResults[this._highlightIndex];
            }
        }
        this.options.onCommit(item, this._displayedQuery);
    };

    /** Update display to reflect value of _highlightIndex, & call onHighlight() */
    QuickSearchField.prototype._updateHighlight = function (explicit) {
        if (this._$dropdown) {
            var $items = this._$dropdown.find("li");
            $items.removeClass("highlight");
            if (this._highlightIndex !== null) {
                $items.eq(this._highlightIndex).addClass("highlight");

                this.options.onHighlight(this._displayedResults[this._highlightIndex], this.$input.val(), explicit);
            }
        }
    };

    /**
     * Refresh the results dropdown, as if the user had changed the search text. Useful for providers that
     * want to show cached data initially, then update the results with fresher data once available.
     */
    QuickSearchField.prototype.updateResults = function () {
        this._pending = null;  // immediately invalidate any previous Promise

        var query = this.$input.val();
        var results = this.options.resultProvider(query);
        if (results.done && results.fail) {
            // Provider returned an async result - mark it as the latest Promise and if it's still latest when
            // it resolves, render the results then
            this._pending = results;
            var self = this;
            this._pending.done(function (realResults) {
                if (self._pending === results) {
                    self._render(realResults, query);
                    this._pending = null;
                }
            });
            if (this._pending) {
                this._pending.fail(function () {
                    if (self._pending === results) {
                        self._render([], query);
                        this._pending = null;
                    }
                });
            }
        } else {
            // Synchronous result - render immediately
            this._render(results, query);
        }
    };


    /** Close dropdown result list if visible */
    QuickSearchField.prototype._closeDropdown = function () {
        if (this._$dropdown) {
            this._$dropdown.remove();
            this._$dropdown = null;
        }
    };

    /**
     * Open dropdown result list & populate with the given content
     * @param {!string} htmlContent
     */
    QuickSearchField.prototype._openDropdown = function (htmlContent) {
        if (!this._$dropdown) {
            var self = this;
            this._$dropdown = $("<ol class='quick-search-container'/>").appendTo("body")
                .css({
                    position: "absolute",
                    top: this._dropdownTop,
                    left: this.$input.offset().left,
                    width: this.$input.outerWidth()
                })
                .click(function (event) {
                    // Unlike the Enter key, where we wait to catch up with typing, clicking commits immediately
                    var $item = $(event.target).closest("li");
                    if ($item.length) {
                        self._doCommit($item.index());
                    }
                });
        }
        this._$dropdown.html(htmlContent);
    };

    /**
     * Given finished provider result, format it into HTML and show in dropdown, and update "no-results" style.
     * If an Enter key commit was pending from earlier, process it now.
     * @param {!Array.<*>} results
     * @param {!string} query
     */
    QuickSearchField.prototype._render = function (results, query) {
        this._displayedQuery = query;
        this._displayedResults = results;
        if (this._firstHighlightIndex >= 0) {
            this._highlightIndex = this._firstHighlightIndex;
        } else {
            this._highlightIndex = null;
        }
        // TODO: fixup to match prev value's item if possible?

        if (results.error || results.length === 0) {
            this._closeDropdown();
            if (this._highlightZeroResults) {
                this.$input.addClass("no-results");
            }
        } else if (results.hasOwnProperty("error")) {
            // Error present but falsy - no results to show, but don't decorate with error style
            this._closeDropdown();
            if (this._highlightZeroResults) {
                this.$input.removeClass("no-results");
            }
        } else {
            if (this._highlightZeroResults) {
                this.$input.removeClass("no-results");
            }

            var count = Math.min(results.length, this.options.maxResults),
                html = "",
                i;
            for (i = 0; i < count; i++) {
                html += this.options.formatter(results[i], query);
            }
            this._openDropdown(html);

            // Highlight top item and trigger highlight callback
            this._updateHighlight(false);
        }

        // If Enter key was pressed earlier, handle it now that we've gotten results back
        if (this._commitPending) {
            this._commitPending = false;
            this._doCommit();
        }
    };


    /**
     * Programmatically changes the search text and updates the results.
     * @param {!string} value
     */
    QuickSearchField.prototype.setText = function (value) {
        this.$input.val(value);
        this.updateResults();  // programmatic changes don't trigger "input" event
    };

    /**
     * Closes the dropdown, and discards any pending Promises.
     */
    QuickSearchField.prototype.destroy = function () {
        this._pending = null;  // immediately invalidate any pending Promise
        this._closeDropdown();
    };


    exports.QuickSearchField = QuickSearchField;
});