/**
* 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);
}
});
}
);