Source: forms/controls/MultiSelect.js

/**
 * Copyright (C) 2005-2016 Alfresco Software Limited.
 *
 * This file is part of Alfresco
 *
 * Alfresco is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Alfresco is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
 */

// TODO: Add ARIA

/**
 * This is the inner controls used by the 
 * [MultiSelectInput]{@link module:alfresco/forms/controls/MultiSelectInput} form control.
 *
 * @module alfresco/forms/controls/MultiSelect
 * @extends external:dijit/_WidgetBase
 * @mixes external:dijit/_TemplatedMixin
 * @mixes external:dijit/_FocusMixin
 * @mixes module:alfresco/forms/controls/utilities/ChoiceMixin
 * @mixes module:alfresco/core/Core
 * @mixes module:alfresco/core/ObjectProcessingMixin
 * @author Martin Doyle
 */
define([
      "alfresco/core/Core",
      "alfresco/core/ObjectProcessingMixin",
      "alfresco/core/ObjectTypeUtils",
      "alfresco/forms/controls/utilities/ChoiceMixin",
      "alfresco/util/functionUtils",
      "dijit/_FocusMixin",
      "dijit/_TemplatedMixin",
      "dijit/_WidgetBase",
      "dojo/_base/array",
      "dojo/_base/declare",
      "dojo/_base/lang",
      "dojo/Deferred",
      "dojo/dom-construct",
      "dojo/dom-geometry",
      "dojo/dom-style",
      "dojo/dom-class",
      "dojo/keys",
      "dojo/on",
      "dojo/when",
      "dojo/text!./templates/MultiSelect.html"
   ],
   function(Core, ObjectProcessingMixin, ObjectTypeUtils, ChoiceMixin, functionUtils, _FocusMixin, _TemplatedMixin, _WidgetBase, array,
      declare, lang, Deferred, domConstruct, domGeom, domStyle, domClass, keys, on, when, template) {

      return declare([_WidgetBase, _TemplatedMixin, _FocusMixin, ChoiceMixin, Core, ObjectProcessingMixin], {

         /**
          * The Choice object (referenced in other JSDoc comments)
          *
          * @instance
          * @typedef {object} Choice
          * @property {object} domNode The main domNode for the choice
          * @property {object} contentNode The content domNode inside the choice
          * @property {object} closeButton The domNode of the close button
          * @property {object} selectListener A remove handle for the choice selection listener
          * @property {object} closeListener A remove handle for the close-button listener
          * @property {object} item The store item
          * @property {string} value The value of this choice
          */

         /**
          * The Result object (referenced in other JSDoc comments)
          *
          * @instance
          * @typedef {object} Result
          * @property {object} domNode The main domNode for the result
          * @property {object} item The store item
          * @property {string} value The value of this choice
          */

         /**
          * The Label object (referenced in other JSDoc comments)
          *
          * @instance
          * @typedef {object} Label
          * @property {string} choice The version of the label used for chosen items
          * @property {string} result The version of the label used for items in the results dropdown
          * @property {string} full The full version of the label, used as the title attribute for choices and results
          */

         /**
          * An array of the CSS files to use with this widget.
          *
          * @instance
          * @type {object[]}
          * @default [{cssFile:"./css/MultiSelect.css"}]
          */
         cssRequirements: [{
            cssFile: "./css/MultiSelect.css"
         }],

         /**
          * An array of the i18n files to use with this widget.
          *
          * @instance
          * @type {object[]}
          * @default [{i18nFile: "./i18n/MultiSelect.properties"}]
          */
         i18nRequirements: [{
            i18nFile: "./i18n/MultiSelect.properties"
         }],

         /**
          * <p>Whether to infer missing properties on retrieved option objects.</p>
          *
          * <p><strong>NOTE:</strong> The "name", "label" and "value" properties are retrieved using
          * the "query", "label" and "value" attribute names as configured in the store.</p>
          *
          * <p>Priorities are:</p>
          *
          * <ul>
          *   <li>Missing name takes label if available, else value</li>
          *   <li>Missing label takes name if available, else value</li>
          *   <li>Missing value takes name if available, else label</li>
          * </ul>
          *
          * @instance
          * @type {boolean}
          * @default
          * @since 1.0.42
          */
         inferMissingProperties: false,

         /**
          * An object that defines the formats of the labels. See main module example for example.
          * It should be a format string for each of the three label strings
          *
          * @type {object}
          * @see {module:alfresco/forms/controls/MultiSelect#Label}
          * @default undefined
          */
         labelFormat: undefined,

         /**
          * The root class of this widget
          *
          * @instance
          * @type {string}
          */
         rootClass: "alfresco-forms-controls-MultiSelect",

         /**
          * The HTML template to use for the widget.
          *
          * @override
          * @instance
          * @type {String}
          */
         templateString: template,

         /**
          * The current value of the control
          *
          * @instance
          * @type {object[]}
          */
         value: null,

         /**
          * An optional token that can be provided for splitting the supplied value. This should be configured
          * when the value is provided as a string that needs to be converted into an array.
          * 
          * @instance
          * @type {string}
          * @default
          * @since 1.0.77
          */
         valueDelimiter: null,

         /**
          * The width of the control, specified as a CSS value (optional)
          *
          * @instance
          * @type {string}
          */
         width: null,

         /**
          * A cache of the current search value
          *
          * @instance
          * @type {string}
          */
         _currentSearchValue: "",

         /**
          * Whether the control is disabled
          *
          * @instance
          * @type {boolean}
          */
         _disabled: false,

         /**
          * The currently focused result item
          *
          * @instance
          * @type {module:alfresco/forms/controls/MultiSelect#Result}
          */
         _focusedResult: null,

         /**
          * Collection of items which are "temporary" and need updating from the store
          * NOTE: It is assumed these items are also in the _storeItems collection,
          *       so their properties can just be updated in-situ
          *
          * @instance
          * @type {object[]}
          */
         _itemsToUpdateFromStore: null,

         /**
          * The index of the latest search request
          *
          * @instance
          * @type {number}
          */
         _latestSearchRequestIndex: 0,

         /**
          * How long a query can run (ms) before a loading message is displayed
          *
          * @instance
          * @type {number}
          */
         _loadingMessageTimeoutMs: 250,

         /**
          * Timeout used to debounce new search requests
          *
          * @instance
          * @type {number}
          */
         _newSearchTimeoutPointer: 0,

         /**
          * Node pointers for dynamic elements created by this widget (and
          * not in the template)
          *
          * @type {object}
          */
         _nodes: null,

         /**
          * Collection of listeners for the results dropdown to help track and
          * remove them when no longer needed
          *
          * @instance
          * @type {object[]}
          */
         _resultListeners: null,

         /**
          * The results
          *
          * @instance
          * @type {Result[]}
          */
         _results: null,

         /**
          * The number of milliseconds to debounce search requests, i.e. the pause
          * needed during typing for a search request to actually kick off
          *
          * @instance
          * @type {number}
          */
         _searchDebounceMs: 100,

         /**
          * A timeout to ensure the loading message does not display if the results
          * come back super-quick
          *
          * @instance
          * @type {number}
          */
         _showLoadingTimeoutPointer: 0,

         /**
          * Sometimes we want to prevent key-up from performing a search, which we
          * will know immediately before in the keypress handler. This property
          * supports that behaviour.
          *
          * @instance
          * @type {boolean}
          */
         _suppressKeyUp: false,

         /**
          * Widget template has been turned into a DOM
          *
          * @override
          * @instance
          */
         buildRendering: function alfresco_forms_controls_MultiSelect__buildRendering() {
            this.inherited(arguments);
            this.width && domStyle.set(this.domNode, "width", this.width);
            this._createDropdown();
         },

         /**
          * Constructor
          *
          * @override
          * @instance
          */
         constructor: function alfresco_forms_controls_MultiSelect__constructor() {
            this._itemsToUpdateFromStore = [];
            this._nodes = {};
            this._results = [];
            this._resultListeners = [];
         },

         /**
          * Widget has been created, but possibly not sub-widgets
          *
          * @override
          * @instance
          */
         postCreate: function alfresco_forms_controls_MultiSelect__postCreate() {
            this.inherited(arguments);
            this._setupDisabling();
            this.own(on(this.domNode, "click", lang.hitch(this, this._onControlClick)));
            this._setupScrollHandling();
            this._preventWidgetDropdownDisconnects();
            this.value = [];
         },

         /**
          * Get the specified property
          *
          * @instance
          * @param {string} propName The name of the property to retrieve
          */
         get: function alfresco_forms_controls_MultiSelect__get(propName) {
            switch (propName) {
               case "value":
                  return this.getValue();
               case "disabled":
                  return this.isDisabled();
            }
         },

         /**
          * Overrides the [inherited function]{@link module:alfresco/forms/controls/utilities/ChoiceMixin#getItemValue}
          * to return the target item mapped in the [store]{@link module:alfresco/forms/controls/MultiSelect#store}.
          *
          * @instance
          * @return {object} The [search box]{@link module:alfresco/forms/controls/MultiSelect#searchBox} element.
          * @since 1.0.54
          */
         getItemValue: function alfresco_forms_controls_utilities_ChoiceMixin__getItemValue(item) {
            return item[this.store.valueAttribute];
         },

         /**
          * Overrides the [inherited function]{@link module:alfresco/forms/controls/utilities/ChoiceMixin#getNewChoiceRelativePosition}
          * indicate that choices should be placed before the [search box]{@link module:alfresco/forms/controls/MultiSelect#searchBox}.
          *
          * @instance
          * @return {object} The [search box]{@link module:alfresco/forms/controls/MultiSelect#searchBox} element.
          * @since 1.0.54
          */
         getNewChoiceRelativePosition: function alfresco_forms_controls_MultiSelect__getNewChoiceTargetNode() {
            return "before";
         },

         /**
          * Overrides the [inherited function]{@link module:alfresco/forms/controls/utilities/ChoiceMixin#getNewChoiceTargetNode}
          * to return the [search box]{@link module:alfresco/forms/controls/MultiSelect#searchBox} as the DOM element to add choices
          * relative to.
          *
          * @instance
          * @return {object} The [search box]{@link module:alfresco/forms/controls/MultiSelect#searchBox} element.
          * @since 1.0.54
          */
         getNewChoiceTargetNode: function alfresco_forms_controls_MultiSelect__getNewChoiceTargetNode() {
            return this.searchBox;
         },

         /**
          * Overrides the [inherited function]{@link module:alfresco/forms/controls/utilities/ChoiceMixin#getStoreItem}
          * to return the mapped item from the [store]{@link module:alfresco/forms/controls/utilities/ChoiceMixin#_storeItems}.
          *
          * @instance
          * @return {object} The [search box]{@link module:alfresco/forms/controls/MultiSelect#searchBox} element.
          * @since 1.0.54
          */
         getStoreItem: function alfresco_forms_controls_utilities_ChoiceMixin__getStoreItem(value) {
            return this._storeItems[value];
         },

         /**
          * Get the value of the control
          *
          * @instance
          * @returns {string[]} The value(s) of the control
          */
         getValue: function alfresco_forms_controls_MultiSelect__getValue() {
            var value = this.value;
            if (value && this.valueDelimiter && ObjectTypeUtils.isArray(value))
            {
               var itemValues = array.map(value, function(valueItem) {
                  return valueItem[this.store.valueAttribute];
               }, this);
               value = itemValues.join(this.valueDelimiter);
            }
            return value;
         },

         /**
          * Whether the control is disabled
          *
          * @instance
          * @returns {Boolean} If disabled then true
          */
         isDisabled: function alfresco_forms_controls_MultiSelect__isDisabled() {
            return this._disabled;
         },

         /**
          * Normalise an individual item, to make the data suitable for use with this widget.
          *
          * @instance
          * @param {object} item The item to be normalised
          * @returns {object} The normalised item (returned for convenience)
          * @since 1.0.42
          */
         normaliseItem: function alfresco_forms_controls_MultiSelect__normaliseItem(item) {
            /*jshint maxcomplexity:false*/

            // Get the current attributes of the item
            var itemName = item[this.store.queryAttribute],
               itemLabel = item[this.store.labelAttribute],
               itemValue = item[this.store.valueAttribute];

            // Infer missing properties
            if (this.inferMissingProperties && (itemName || itemLabel || itemValue)) {
               if (itemName && !itemLabel && !itemValue) {
                  itemLabel = itemValue = itemName;
               } else if (!itemName && itemLabel && !itemValue) {
                  itemName = itemValue = itemLabel;
               } else if (!itemName && !itemLabel && itemValue) {
                  itemName = itemLabel = itemValue;
               } else if (!itemName) {
                  itemName = itemLabel;
               } else if (!itemLabel) {
                  itemLabel = itemName;
               } else if (!itemValue) {
                  itemValue = itemName;
               }
               item[this.store.queryAttribute] = itemName;
               item[this.store.labelAttribute] = itemLabel;
               item[this.store.valueAttribute] = itemValue;
            } else if (!this.inferMissingProperties) {
               array.forEach(["query", "label", "value"], function(attrType) {
                  var attrName = this.store[attrType + "Attribute"];
                  if (!item[attrName]) {
                     this.alfLog("warn", "Missing \"" + attrName + "\" property on retrieved item: ", item);
                  }
               }, this);
            }

            // Items MUST have values for the widget to work
            if (itemValue === null || typeof itemValue === "undefined") {
               this.alfLog("error", "Option provided to MultiSelect control does not have a value: ", item);
            }

            // Pass back the modified item for convenience (it's already been mutated)
            return item;
         },

         /**
          * Set the specified property
          *
          * @instance
          * @param {string} propName The name of the property to update
          * @param {*} propValue The new value
          */
         set: function alfresco_forms_controls_MultiSelect__set(propName, propValue) {
            switch (propName) {
               case "value":
                  this.setValue(propValue);
                  break;
               case "disabled":
                  this.setDisabled(propValue);
                  break;
            }
         },

         /**
          * Set whether the control is disabled
          *
          * @instance
          * @param {boolean} newValueParam True to disable, false to enable
          */
         setDisabled: function alfresco_forms_controls_MultiSelect__setDisabled(newValueParam) {
            this._disabled = !!newValueParam;
            this.searchBox.disabled = this._disabled;
            domClass[this._disabled ? "add" : "remove"](this.domNode, this.rootClass + "--disabled");
         },

         /**
          * Set the value of the control
          *
          * @instance
          * @param    {string|string[]|object|object[]} newValue The new value(s)
          */
         setValue: function alfresco_forms_controls_MultiSelect__setValue(newValueParam) {

            // Setup helper vars
            var newValuesArray = newValueParam;
            if (newValueParam && this.valueDelimiter)
            {
               newValuesArray = newValueParam.split(this.valueDelimiter);
            }
            else if (!ObjectTypeUtils.isArray(newValuesArray)) {
               newValuesArray = (newValueParam && [newValueParam]) || [];
            }

            // Clear existing values
            array.forEach(this._choices, this._removeChoice, this);

            // Normalise the passed-in values
            var normalisedValues = array.map(newValuesArray, function(nextNewValue) {
               var valueIsString = typeof nextNewValue === "string",
                  item;
               if (valueIsString) {
                  item = {};
                  item[this.store.queryAttribute] = item[this.store.labelAttribute] = item[this.store.valueAttribute] = nextNewValue;
               } else {
                  item = this.normaliseItem(nextNewValue);
               }
               return item;
            }, this);

            // Create an items array
            var chosenItems = array.map(normalisedValues, function(nextItem) {
               /*jshint maxcomplexity:false*/

               // Try and get the value from the existing result items
               var itemValue = nextItem[this.store.valueAttribute],
                  storeItem = this._storeItems[itemValue];

               // If we already have the item, return immediately
               if (storeItem) {
                  return storeItem;
               }

               // Put the new item into the items map
               this._storeItems[itemValue] = nextItem;

               // Mark this item as needing updating from the store
               this._itemsToUpdateFromStore.push(nextItem);

               // Pass back the final item
               return nextItem;

            }, this);

            // Add the choices to the control and kick off the label retrieval if necessary
            array.forEach(chosenItems, this._addChoice, this);
            this._updateItemsFromStore();
            this._updateResultsDropdown();

            // Set the new value
            this._changeAttrValue("value", chosenItems);
         },

         /**
          * Choose the focused item in the results dropdown
          *
          * @instance
          * @returns {boolean} Returns true if item is chosen
          */
         _chooseFocusedItem: function alfresco_forms_controls_MultiSelect___chooseFocusedItem() {

            // If there is no chosen item, do nothing
            var focusedResult = this._focusedResult;
            if (!focusedResult) {
               return false;
            }

            // Add the choice
            this._addChoice(focusedResult.item);

            // Update the control
            this._resetControl();
            this._updateResultsDropdown();

            // Return true to indicate item was chosen
            return true;
         },

         /**
          * Create this control's dropdown element, which must be at page level to
          * ensure its stacking context permits proper display even within dialogs.
          *
          * @instance
          */
         _createDropdown: function alfresco_forms_controls_MultiSelect___createDropdown() {
            var dropdown = domConstruct.create("ul", {
                  className: this.rootClass + "__results",
                  id: this.id + "_RESULTS"
               }, document.body),
               loading = domConstruct.create("li", {
                  className: this.rootClass + "__results__loading-message",
                  innerHTML: this.message("multiselect.loading")
               }, dropdown),
               noResults = domConstruct.create("li", {
                  className: this.rootClass + "__results__no-results-message"
               }, dropdown),
               errorMessage = domConstruct.create("li", {
                  className: this.rootClass + "__results__error-message"
               }, dropdown);
            this.own({
               remove: function() {
                  domConstruct.destroy(dropdown);
               }
            });
            lang.mixin(this._nodes, {
               resultsDropdown: dropdown,
               loadingMessage: loading,
               noResultsMessage: noResults,
               errorMessage: errorMessage
            });
         },

         /**
          * Create a document fragment of a label, highlighted with the current search term
          *
          * @instance
          * @param    {string} resultLabel The label
          * @returns  {object} A document fragment of the highlighted label
          */
         _createHighlightedResultLabel: function alfresco_forms_controls_MultiSelect___createHighlightedResultLabel(resultLabel) {

            // Create variables
            var resultLabelFrag = document.createDocumentFragment();

            // Do we have a current search value
            if (!this._currentSearchValue) {

               // No highlighting
               resultLabelFrag.appendChild(document.createTextNode(resultLabel));

            } else {

               // Run the regex against the label
               var searchRegex = this.store.createSearchRegex(this._currentSearchValue, true),
                  searchResults = searchRegex.exec(resultLabel),
                  matchedText = searchResults[0],
                  matchedIndex = searchResults.index,
                  beforeMatch = resultLabel.substr(0, matchedIndex),
                  afterMatch = resultLabel.substr(matchedIndex + matchedText.length);

               // Populate the fragment
               beforeMatch && resultLabelFrag.appendChild(document.createTextNode(beforeMatch));
               domConstruct.create("span", {
                  className: this.rootClass + "__results__result__highlighted-label"
               }, resultLabelFrag).appendChild(document.createTextNode(matchedText));
               afterMatch && resultLabelFrag.appendChild(document.createTextNode(afterMatch));

            }

            // Pass back the match
            return resultLabelFrag;
         },

         /**
          * Do not fire multiple searches needlessly. Debounce the search requests,
          * i.e. wait until the user has paused typing to actually do the search.
          *
          * @instance
          * @param    {string} searchString The search string to use
          */
         _debounceNewSearch: function alfresco_forms_controls_MultiSelect___debounceNewSeach(searchString) {
            clearTimeout(this._newSearchTimeoutPointer);
            this._newSearchTimeoutPointer = setTimeout(lang.hitch(this, function() {
               this._startSearch(searchString);
            }), this._searchDebounceMs);
         },

         /**
          * Empty the results dropdown
          *
          * @instance
          */
         _emptyResults: function alfresco_forms_controls_MultiSelect___emptyResults() {
            var resultListener;
            while ((resultListener = this._resultListeners.pop())) {
               resultListener.remove();
            }
            while (this._nodes.resultsDropdown.childNodes.length > 3) {
               this._nodes.resultsDropdown.removeChild(this._nodes.resultsDropdown.firstChild);
            }
            this._focusedResult = null;
         },

         /**
          * Get the cursor position within the search box
          * NOTE: Uses code derived from http://javascript.nwbox.com/cursor_position
          *
          * @instance
          * @returns  {number} The cursor position (zero-indexed)
          */
         _getCursorPositionWithinTextbox: function alfresco_forms_controls_MultiSelect___getCursorPositionWithinTextbox() {
            var cursorPos = 0,
               range;
            if (this.searchBox.createTextRange && document.selection) { // IE11 passes first condition, but fails on second (AKU-306)
               range = document.selection.createRange().duplicate();
               range.moveEnd("character", this.searchBox.value.length);
               if (!range.text) {
                  cursorPos = this.searchBox.value.length;
               } else {
                  cursorPos = this.searchBox.value.lastIndexOf(range.text);
               }
            } else {
               cursorPos = this.searchBox.selectionStart;
            }
            return cursorPos;
         },

         /**
          * Overrides the [inherited function]{@link module:alfresco/forms/controls/utilities/ChoiceMixin#_getLabel} to
          * return a more complex label object to satisfy requirements of the drop-down.
          *
          * @instance
          * @param    {item} item The item whose label to retrieve
          * @returns {module:alfresco/forms/controls/MultiSelect#Label}
          */
         _getLabel: function alfresco_forms_controls_MultiSelect___getLabel(item) {

            // Setup the label format strings
            var choice = (this.labelFormat && this.labelFormat.choice) || item[this.store.labelAttribute],
               result = (this.labelFormat && this.labelFormat.result) || item[this.store.labelAttribute],
               full = (this.labelFormat && this.labelFormat.full) || item[this.store.labelAttribute],
               labelObj = {
                  "choice": this.processTokens(choice, item),
                  "result": this.processTokens(result, item),
                  "full": this.processTokens(full, item)
               };

            // Setup and return the label object
            return labelObj;
         },

         /**
          * Go to the next result in the dropdown, or the first one if none selected (ignores already-chosen items)
          *
          * @instance
          * @param {boolean} reverseCommand If true then go to previous item instead
          */
         _gotoNextResult: function alfresco_forms_controls_MultiSelect___gotoNextResult(reverseCommand) {
            var results = this._results.slice(0), // Clone the array, so we can reverse if necessary
               focusedClass = this.rootClass + "__results__result--focused",
               alreadyChosenClass = this.rootClass + "__results__result--already-chosen",
               focusNextResult = false,
               resultToFocus;
            if (reverseCommand) {
               results.reverse();
            }
            array.forEach(results, function(nextResult) {
               if (!resultToFocus) {
                  var resultIsChosen = domClass.contains(nextResult.domNode, alreadyChosenClass),
                     resultIsFocused = this._focusedResult === nextResult,
                     resultIsValid = !resultIsChosen && (!this._focusedResult || focusNextResult);
                  if (resultIsValid) {
                     resultToFocus = nextResult;
                  } else if (resultIsFocused) {
                     focusNextResult = true;
                  }
               }
               domClass.remove(nextResult.domNode, focusedClass);
            }, this);
            if (!resultToFocus && this._focusedResult) {
               resultToFocus = this._focusedResult;
            }
            if (resultToFocus) {
               this._focusedResult = resultToFocus;
               domClass.add(resultToFocus.domNode, focusedClass);
            } else {
               this._focusedResult = null;
            }
         },

         /**
          * Handle failures that occur when calling the search service
          *
          * @instance
          * @param    {object} err The error object
          */
         _handleSearchFailure: function alfresco_forms_controls_MultiSelect___handleSearchFailure(err) {

            // Remove old results
            this._emptyResults();

            // Hide loading and show error
            this._hideLoadingMessage();
            this._showErrorMessage(err.message);

            // Log full error details
            this.alfLog("error", "Error occurred during search: ", err);
         },

         /**
          * Handle the (successful) response from the search service
          *
          * @instance
          * @param    {object} responseItems The response items
          */
         _handleSearchSuccess: function alfresco_forms_controls_MultiSelect___handleSearchSuccess(responseItems) {
            this._hideLoadingMessage();
            this._results = array.map(responseItems, function(nextItem) {
               var safeItem = this.normaliseItem(nextItem),
                  value = safeItem[this.store.valueAttribute];
               this._storeItems[value] = safeItem;
               return {
                  domNode: null,
                  item: safeItem,
                  value: value
               };
            }, this);
            this._updateResultsDropdown();
            if (!responseItems.length) {
               this._showEmptyMessage();
            } else if (!this._focusedResult) {
               this._gotoNextResult();
            }
            this._showResultsDropdown();
         },

         /**
          * Hide the empty message in the dropdown
          *
          * @instance
          */
         _hideEmptyMessage: function alfresco_forms_controls_MultiSelect___hideEmptyMessage() {
            domClass.remove(this._nodes.resultsDropdown, this.rootClass + "__results--empty");
         },

         /**
          * Hide the error message in the dropdown
          *
          * @instance
          */
         _hideErrorMessage: function alfresco_forms_controls_MultiSelect___hideError() {
            domClass.remove(this._nodes.resultsDropdown, this.rootClass + "__results--error");
         },

         /**
          * Hide the loading message in the dropdown
          *
          * @instance
          */
         _hideLoadingMessage: function alfresco_forms_controls_MultiSelect___hideLoading() {
            domClass.remove(this._nodes.resultsDropdown, this.rootClass + "__results--loading");
            clearTimeout(this._showLoadingTimeoutPointer);
         },

         /**
          * Hide the results dropdown
          *
          * @instance
          */
         _hideResultsDropdown: function alfresco_forms_controls_MultiSelect___hideResults() {
            domClass.remove(this._nodes.resultsDropdown, this.rootClass + "__results--visible");
            this._hideLoadingMessage();
            this._hideErrorMessage();
         },

         /**
          * Handle blur events on the search box
          *
          * @instance
          */
         _onBlur: function alfresco_forms_controls_MultiSelect___onSearchBlur() {
            domClass.remove(this.domNode, this.rootClass + "--focused");
            this._deselectAllChoices();
            this._hideResultsDropdown();
         },

         /**
          * Extends the [inherited function]{@link module:alfresco/forms/controls/utilities/ChoiceMixin#_onChoiceClick} to
          * [remove the focus from results]{@link module:alfresco/forms/controls/MultiSelect#_unfocusResults}.
          *
          * @instance
          * @param    {object} choiceObject The choice (node) being clicked on
          * @param    {object} evt Dojo-normalised event object
          */
         _onChoiceClick: function alfresco_forms_controls_MultiSelect___onChoiceClick(/*jshint unused:false*/ choiceObject, evt) {
            this.inherited(arguments);
            this._unfocusResults();
         },

         /**
          * Handle clicks on the control
          *
          * @instance
          * @param    {object} evt Dojo-normalised event object
          */
         _onControlClick: function alfresco_forms_controls_MultiSelect___onControlClick(evt) {
            this._deselectAllChoices();
            if (evt.target !== this.searchBox) {
               this.searchBox.focus();
            }
            this._showOrSearch();
         },

         /**
          * Handle focus events on this control
          *
          * @instance
          */
         _onFocus: function alfresco_forms_controls_MultiSelect___onFocus() {
            domClass.add(this.domNode, this.rootClass + "--focused");
            this._showOrSearch();
         },

         /**
          * Handle mousedowns on the result items
          * NOTE: We're using mousedown rather than click to evade problems with the searchBox blur event
          *
          * @instance
          */
         _onResultMousedown: function alfresco_forms_controls_MultiSelect___onResultMousedown() {
            this._chooseFocusedItem();
         },

         /**
          * Handle mouseovers on the result items
          *
          * @instance
          * @param    {object} evt Dojo-normalised event object
          */
         _onResultMouseover: function alfresco_forms_controls_MultiSelect___onResultMouseover(evt) {
            var focusedClass = this.rootClass + "__results__result--focused",
               alreadyChosenClass = this.rootClass + "__results__result--already-chosen",
               hoveredResultNode = evt.currentTarget,
               hoveredResultAlreadyChosen = domClass.contains(hoveredResultNode, alreadyChosenClass);
            this._focusedResult = null;
            array.forEach(this._results, function(nextResult) {
               var nextResultIsHovered = nextResult.domNode === hoveredResultNode,
                  doFocus = nextResultIsHovered && !hoveredResultAlreadyChosen;
               if (doFocus) {
                  this._focusedResult = nextResult;
                  domClass.add(nextResult.domNode, focusedClass);
               } else {
                  domClass.remove(nextResult.domNode, focusedClass);
               }
            }, this);
         },

         /**
          * Handle changes to the search box value
          *
          * @instance
          * @param {string} newValue The new search value
          */
         _onSearchChange: function alfresco_forms_controls_MultiSelect___onSearchChange(newValue) {

            // Get new value
            this._currentSearchValue = newValue;

            // Update searchBox size
            this.offScreenSearch.textContent = newValue;
            var contentWidth = this.offScreenSearch.offsetWidth;
            domStyle.set(this.searchBox, "width", (contentWidth + 20) + "px");

            // Start a new search
            this._debounceNewSearch(newValue);
         },

         /**
          * Handle keypress events on the search box
          *
          * @instance
          * @param {object} evt Dojo-normalised event object
          */
         _onSearchKeypress: function alfresco_forms_controls_MultiSelect___onSearchKeypress(evt) {
            /*jshint maxcomplexity:false*/
            var cursorPosBeforeKeypress = this._getCursorPositionWithinTextbox(),
               modifiersPressed = evt.ctrlKey || evt.altKey || evt.shiftKey || evt.metaKey;
            if (!modifiersPressed) {
               switch (evt.charOrCode) {
                  case keys.ESCAPE:
                     this._resetControl();
                     this._suppressKeyUp = true;
                     break;
                  case keys.ENTER:
                     if (this._resultsDropdownIsVisible()) {
                        this._chooseFocusedItem();
                     }
                     evt.preventDefault();
                     evt.stopPropagation();
                     break;
                  case keys.DOWN_ARROW:
                     if (this._resultsDropdownIsVisible()) {
                        this._gotoNextResult();
                     } else {
                        this._showOrSearch();
                     }
                     evt.preventDefault();
                     break;
                  case keys.UP_ARROW:
                     if (this._resultsDropdownIsVisible()) {
                        this._gotoNextResult(true);
                     }
                     evt.preventDefault();
                     break;
                  case keys.LEFT_ARROW:
                     if (cursorPosBeforeKeypress === 0) {
                        this._selectChoice(-1);
                     }
                     break;
                  case keys.RIGHT_ARROW:
                     if (this._selectedChoice) {
                        this._selectChoice(1);
                        evt.preventDefault();
                     }
                     break;
                  case keys.BACKSPACE:
                     if (cursorPosBeforeKeypress === 0 && this._choices.length && !this._selectedChoice) {
                        this._selectChoice(-1);
                     }
                     /* falls through */
                  case keys.DELETE:
                     if (this._selectedChoice) {
                        this._deleteSelectedChoice();
                        evt.preventDefault();
                     }
                     break;
                  default:
                     // Allow to continue
               }
            }
         },

         /**
          * Handle keyup events on the search box
          *
          * @instance
          * @param {object} evt Dojo-normalised event object
          */
         _onSearchKeyup: function alfresco_forms_controls_MultiSelect___onSearchKeyup( /*jshint unused:false*/ evt) {
            if (this._suppressKeyUp) {
               this._suppressKeyUp = false;
            } else {
               this._onSearchUpdate();
            }
         },

         /**
          * Handle updates to the search box, which may or may not result in
          * the search value having changed
          *
          * @instance
          */
         _onSearchUpdate: function alfresco_forms_controls_MultiSelect___onSearchUpdate() {
            var trimmedValue = this.searchBox.value.replace(/^\s+|\s+$/g, "");
            if (this._currentSearchValue !== trimmedValue) {
               this._onSearchChange(trimmedValue);
            }
         },

         /**
          * Position the dropdown appropriately
          *
          * @instance
          */
         _positionDropdown: function alfresco_forms_controls_MultiSelect___positionDropdown() {
            var includeScroll = true,
               widgetPos = domGeom.position(this.domNode, includeScroll);
            domStyle.set(this._nodes.resultsDropdown, {
               width: widgetPos.w + "px",
               left: widgetPos.x + "px",
               top: (widgetPos.y + widgetPos.h - 1) + "px"
            });
         },

         /**
          * Prevent the absolutely positioned dropdown from being disconnected from
          * the main widget. Because the dropdown is positioned every time it's
          * displayed, all we need to do is hide it when we detect a circumstance
          * that could cause a disconnect.
          *
          * @instance
          */
         _preventWidgetDropdownDisconnects: function alfresco_forms_controls_MultiSelect___preventWidgetDropdownDisconnects() {

            // When we're in a dialog, we want to hide the results. There is never going to be a situation
            // (assumption) where a dialog moving is going to cause a problem if we hide the results dropdown
            // so let's just always hide it
            this.alfSubscribe("ALF_DIALOG_MOVE_START", lang.hitch(this, this._hideResultsDropdown), true);

            // Although the dropdown will move with the body scrolling, it won't cope with scrollable elements
            // moving, so we'll go up the tree trying to find any, and then listen for their scroll events and
            // again hide the dropdown when it happens
            var nextParent = this.domNode;
            while ((nextParent = nextParent.parentNode) && nextParent.tagName !== "body") {
               if (nextParent.scrollHeight > nextParent.offsetHeight) {
                  this.own(on(nextParent, "scroll", lang.hitch(this, this._hideResultsDropdown)));
               }
            }
         },

         /**
          * Extends the [inherited function]{@link module:alfresco/forms/controls/utilities/ChoiceMixin#_removeChoice} to
          * [update the results dropdown]{@link module:alfresco/forms/controls/MultiSelect#_updateResultsDropdown} and 
          * [hide it]{@link module:alfresco/forms/controls/MultiSelect#_hideResultsDropdown}.
          *
          * @instance
          * @param    {object} choiceToRemove The choice object to remove
          * @param    {object} evt Dojo-normalised event object
          */
         _removeChoice: function alfresco_forms_controls_MultiSelect___removeChoice(/*jshint unused:false*/ choiceToRemove) {
            this.inherited(arguments);

            // Update the results (i.e. adjust the items' chosen states)
            this._updateResultsDropdown();
            this._hideResultsDropdown();
         },

         /**
          * Reset the control. Empties the search box, hides the dropdown and cancels any pending requests
          *
          * @instance
          */
         _resetControl: function alfresco_forms_controls_MultiSelect___resetControl() {
            clearTimeout(this._newSearchTimeoutPointer); // Prevent any pending search
            this._latestSearchRequestIndex++; // Invalidate any existing request index
            this._currentSearchValue = ""; // Reset the current search
            this._results = []; // Remove all results
            this._hideResultsDropdown(); // Hide the dropdown
            setTimeout(lang.hitch(this, function() {
               this.searchBox.value = ""; // Empty the search box (FF needs this in a setTimeout)
            }), 0);
         },

         /**
          * Test whether the results dropdown is currently visible
          *
          * @instance
          * @returns  {boolean} The results dropdown's visibility
          */
         _resultsDropdownIsVisible: function alfresco_forms_controls_MultiSelect___resultsDropdownIsVisible() {
            return domClass.contains(this._nodes.resultsDropdown, this.rootClass + "__results--visible");
         },

         /**
          * Select the specified choice
          *
          * @instance
          * @param    {object|number} choiceNodeOrOffset The choice node to select or the adjustment offset from
          *                                              the currently selected one, which must be either 1 or -1.
          *                                              If none is selected, then the start position is to the
          *                                              right of the current choices.
          */
         _selectChoice: function alfresco_forms_controls_MultiSelect___selectChoice(/*jshint unused:false*/ choiceNodeOrOffset) {
            this.inherited(arguments);
            this.searchBox.focus();
         },

         /**
          * Consolidate the disabled-state changes into a single method, rather than peppering
          * the code with lots of little snippets.
          *
          * @instance
          */
         _setupDisabling: function alfresco_forms_controls_MultiSelect___setupDisabling() {
            var methodsToDisable = [
               "_onChoiceClick",
               "_onChoiceCloseMouseDown",
               "_onControlClick",
               "_onFocus",
               "_onResultMousedown",
               "_onResultMouseover",
               "_onSearchChange",
               "_onSearchKeypress",
               "_onSearchKeyup",
               "_onSearchUpdate"
            ];
            array.forEach(methodsToDisable, function(methodName) {
               var oldMethodName = "_OLD" + methodName;
               this[oldMethodName] = this[methodName];
               this[methodName] = lang.hitch(this, function() {
                  if (this._disabled) {
                     return;
                  } else {
                     return this[oldMethodName].apply(this, arguments);
                  }
               });
            }, this);
         },

         /**
          * Setup listening for scrolls happening which can affect the position of this control
          * and hence the dropdown
          *
          * @instance
          * @since 1.0.33
          */
         _setupScrollHandling: function alfresco_forms_controls_MultiSelect___setupScrollHandling() {

            // Find all ancestor elements that could scroll            
            var scrollingElems = [window],
               testElem = this.domNode;
            while ((testElem = testElem.parentNode) && testElem.tagName !== "BODY") {
               if (testElem.scrollHeight > testElem.clientHeight) {
                  scrollingElems.push(scrollingElems);
               }
            }

            // Add listener to each one that will call a throttled re-position
            array.forEach(scrollingElems, function(scrollElem) {
               this.own(on(scrollElem, "scroll", lang.hitch(this, function() {
                  functionUtils.throttle({
                     name: this.id,
                     func: lang.hitch(this, this._positionDropdown),
                     timeoutMs: 50
                  });
               })));
            }, this);
         },

         /**
          * Show the empty message in the dropdown
          *
          * @instance
          */
         _showEmptyMessage: function alfresco_forms_controls_MultiSelect___showEmptyMessage() {
            while (this._nodes.noResultsMessage.hasChildNodes()) {
               this._nodes.noResultsMessage.removeChild(this._nodes.noResultsMessage.firstChild);
            }
            var emptyMessage = this.message("multiselect.noresults", this._currentSearchValue);
            this._nodes.noResultsMessage.appendChild(document.createTextNode(emptyMessage));
            domClass.add(this._nodes.resultsDropdown, this.rootClass + "__results--empty");
            this._hideErrorMessage();
            this._hideLoadingMessage();
            this._showResultsDropdown();
         },

         /**
          * Show the error message in the dropdown
          *
          * @instance
          * @param {string} message The error message to be shown
          */
         _showErrorMessage: function alfresco_forms_controls_MultiSelect___showError(message) {

            // Remove old message and insert new one
            while (this._nodes.errorMessage.hasChildNodes()) {
               this._nodes.errorMessage.removeChild(this._nodes.errorMessage.firstChild);
            }
            this._nodes.errorMessage.appendChild(document.createTextNode(message));

            // Show the error (and hide any loading indicator)
            domClass.add(this.domNode, this.rootClass + "--has-error");
            domClass.add(this._nodes.resultsDropdown, this.rootClass + "__results--error");
            this._hideEmptyMessage();
            this._hideLoadingMessage();
            this._showResultsDropdown();
         },

         /**
          * Show the loading message in the dropdown
          *
          * @instance
          */
         _showLoadingMessage: function alfresco_forms_controls_MultiSelect___showLoading() {
            domClass.add(this._nodes.resultsDropdown, this.rootClass + "__results--loading");
            this._hideEmptyMessage();
            this._hideErrorMessage();
            this._showResultsDropdown();
         },

         /**
          * If we have current results then open the dropdown, otherwise perform a new search.
          *
          * @instance
          */
         _showOrSearch: function() {
            if (this._results.length) {
               if (!this._resultsDropdownIsVisible()) {
                  this._showResultsDropdown();
                  if (!this._focusedResult) {
                     this._gotoNextResult();
                  }
               }
            } else {
               this._debounceNewSearch(this.searchBox.value);
            }
         },

         /**
          * Show the results dropdown
          *
          * @instance
          */
         _showResultsDropdown: function alfresco_forms_controls_MultiSelect___showResults() {
            this._positionDropdown();
            domClass.add(this._nodes.resultsDropdown, this.rootClass + "__results--visible");
         },

         /**
          * Start a new search
          *
          * @instance
          * @param    {string} searchString The string to search on
          */
         _startSearch: function alfresco_forms_controls_MultiSelect___startSearch(searchString) {

            // Hide existing result items
            this._hideLoadingMessage();
            this._hideErrorMessage();
            this._hideEmptyMessage();

            // Setup handlers and update request "counter"
            var thisRequestIndex = this._latestSearchRequestIndex = this._latestSearchRequestIndex + 1,
               successHandler = lang.hitch(this, function(responseItems) {
                  if (thisRequestIndex === this._latestSearchRequestIndex) {
                     this._handleSearchSuccess(responseItems);
                  }
               }),
               failureHandler = lang.hitch(this, function(err) {
                  if (thisRequestIndex === this._latestSearchRequestIndex) {
                     this._handleSearchFailure(err);
                  }
               }),
               queryObj = {};

            // Make the query
            queryObj[this.store.queryAttribute] = searchString;
            this._showLoadingTimeoutPointer = setTimeout(lang.hitch(this, this._showLoadingMessage), this._loadingMessageTimeoutMs);
            when(this.store.query(queryObj), successHandler, failureHandler);
         },

         /**
          * Update the results list
          *
          * @instance
          */
         _updateResultsDropdown: function alfresco_forms_controls_MultiSelect___updateResultsDropdown() {

            // Remove all existing listeners and reset the focused result
            array.forEach(this._resultListeners, function(nextListener) {
               nextListener.remove();
            });
            this._focusedResult = null;

            // If we have too many current results displaying, remove the excess nodes
            while (this._nodes.resultsDropdown.childNodes.length > 3) {
               this._nodes.resultsDropdown.removeChild(this._nodes.resultsDropdown.firstChild);
            }

            // Update or add as necessary
            array.forEach(this._results, function(nextResult, index) {

               // Setup variables
               var resultIsChosen = array.some(this._choices, function(nextChoice) {
                     return nextChoice.value === nextResult.value;
                  });
               var labelObj = this._getLabel(nextResult.item),
                  clickListener,
                  mouseoverListener;

               // The last 3 elements in the resultsDropDown are for indicating "state" so
               // we want to avoid re-using any of those special nodes as a result...
               var itemNode;
               if (index < this._nodes.resultsDropdown.childNodes - 3)
               {
                  itemNode = this._nodes.resultsDropdown.childNodes[index];
               }
               
               // Construct the item if not already present
               if (!itemNode) {
                  itemNode = domConstruct.create("li", {
                     "className": this.rootClass + "__results__result"
                  }, this._nodes.loadingMessage, "before");
               }

               // Update the title
               itemNode.setAttribute("title", labelObj.full);

               // Recreate the label
               var labelFrag = this._createHighlightedResultLabel(labelObj.result);
               domConstruct.empty(itemNode);
               itemNode.appendChild(labelFrag);

               // Setup event listeners
               clickListener = on(itemNode, "mousedown", lang.hitch(this, this._onResultMousedown));
               mouseoverListener = on(itemNode, "mouseover", lang.hitch(this, this._onResultMouseover));
               this._resultListeners.push(clickListener, mouseoverListener);
               this.own(clickListener, mouseoverListener);

               // Update the domNode in the result item
               nextResult.domNode = itemNode;

               // Mark item as chosen, if appropriate and remove focused indicator
               domClass[resultIsChosen ? "add" : "remove"](itemNode, this.rootClass + "__results__result--already-chosen");
               domClass.remove(itemNode, this.rootClass + "__results__result--focused");

            }, this);
         },

         /**
          * Update all of the items in [_itemsToUpdateFromStore]{@link module:alfresco/forms/controls/MultiSelect#_itemsToUpdateFromStore} with info from the store
          *
          * @instance
          */
         _updateItemsFromStore: function alfresco_forms_controls_MultiSelect___updateItemsFromStore() {

            // Make sure we have some that need updating
            if (!this._itemsToUpdateFromStore.length) {

               // Add loaded CSS-state to control
               domClass.add(this.domNode, this.rootClass + "--loaded");

               // Nothing else needed!
               return;
            }

            // Setup handlers
            var successHandler = lang.hitch(this, function(responseItems) {

                  // Normalise the response items
                  var normalisedItems = array.map(responseItems, this.normaliseItem, this);

                  // Update the result items map
                  array.forEach(normalisedItems, function(nextItem) {
                     var value = nextItem[this.store.valueAttribute];
                     if (this._storeItems[value]) {
                        lang.mixin(this._storeItems[value], nextItem);
                     } else {
                        this._storeItems[value] = nextItem;
                     }
                  }, this);

                  // Run through the choices, updating their labels
                  array.forEach(this._choices, function(nextChoice) {
                     var contentNode = nextChoice.contentNode,
                        labelObj = this._getLabel(nextChoice.item);
                     while (contentNode.hasChildNodes()) {
                        contentNode.removeChild(contentNode.firstChild);
                     }
                     contentNode.appendChild(document.createTextNode(labelObj.choice));
                     contentNode.setAttribute("title", labelObj.full);
                  }, this);

                  // Add loaded CSS-state to control
                  domClass.add(this.domNode, this.rootClass + "--loaded");

               }),
               failureHandler = lang.hitch(this, function(err) {

                  // Log the error
                  this.alfLog("error", "Error updating labels from store", err);

                  // Add loaded CSS-state to control
                  domClass.add(this.domNode, this.rootClass + "--loaded");

               });

            // Make the query
            var queryObj = {};
            queryObj[this.store.queryAttribute] = "";
            when(this.store.query(queryObj), successHandler, failureHandler);
         },

         /**
          * Un-focus all results
          *
          * @instance
          */
         _unfocusResults: function alfresco_forms_controls_MultiSelect___unfocusResults() {
            this._focusedResult = null;
            array.forEach(this._results, function(nextResult) {
               domClass.remove(nextResult.domNode, this.rootClass + "__results__result--focused");
            }, this);
         }
      });
   }
);