define('jira/ajs/select/queryable-dropdown-select', ['jira/util/formatter', 'jira/jquery/deferred', 'jira/ajs/control', 'jira/ajs/select/suggestions/default-suggest-handler', 'jira/ajs/layer/inline-layer-factory', 'jira/ajs/list/list', 'jira/util/key-code', 'jira/util/navigator', 'jira/util/assistive', 'jquery', 'underscore'], function (formatter, Deferred, Control, DefaultSuggestHandler, InlineLayerFactory, List, keyCodes, Navigator, Assistive, jQuery, _) {
'use strict';
var SUGGESTIONS_ID_SUFFIX = '-suggestions';
var CONTROL_ID = 0;
/**
* A dropdown that can be queried and it's links selected via keyboard. Dropdown contents retrieved via AJAX.
*
* @class QueryableDropdownSelect
* @extends Control
*/
return Control.extend({
/**
* A request will not be fired and suggestions will not reset if any of these keys are inputted.
*
* @enum
*/
INVALID_KEYS: {
"Shift": true,
"Esc": true,
"Right": true
},
/**
* Overrides default options with user options. Inserts an input field before dropdown.
*
* @param {Object} options
* @param {jQuery | HTMLElement} options.element
* @param {SuggestHandler} options.suggestionsHandler
* @constructs
*/
init: function init(options) {
this.suggestionsVisible = false;
this._setOptions(options);
this._createFurniture();
this._createDropdownController();
this._createSuggestionsController();
this._createListController();
this._assignEventsToFurniture();
if (this.options.width) {
this.setFieldWidth(this.options.width);
}
if (this.options.loadOnInit) {
// eagerly get suggestions
this.requestSuggestions(true);
}
},
/**
* Creates dropdown controller
*/
_createDropdownController: function _createDropdownController() {
var instance = this;
if (this.options.dropdownController) {
this.dropdownController = this.options.dropdownController;
} else {
this.dropdownController = InlineLayerFactory.createInlineLayers({
offsetTarget: this.$field,
width: this.$field.innerWidth(),
content: this.options.element
});
}
this.dropdownController.onhide(function () {
instance.hideSuggestions();
});
},
/**
* Creates suggestions controller
*/
_createSuggestionsController: function _createSuggestionsController() {
var HandlerClass = this.options.suggestionsHandler ? this.options.suggestionsHandler : DefaultSuggestHandler;
this.suggestionsHandler = new HandlerClass(this.options);
},
/**
* Creates list controller
*/
_createListController: function _createListController() {
var instance = this;
this.listController = new List({
containerSelector: this.options.element,
groupSelector: "ul.aui-list-section",
matchingStrategy: this.options.matchingStrategy,
eventTarget: this.$field,
selectionHandler: function selectionHandler() {
// prevent form field from being dirty
instance.$field.val(formatter.I18n.getText("common.concepts.loading")).css("color", "#999");
instance.hideSuggestions();
return true;
}
});
},
_onItemFocus: function _onItemFocus(e, item) {
var $field = this.$field;
var id = item && item.id ? item.id : "null";
Assistive.wait(function () {
$field.attr("aria-activedescendant", id);
});
},
/**
* Sets field width
*
* @param {Number} width - field width
*/
setFieldWidth: function setFieldWidth(width) {
this.$container.css({
width: width,
minWidth: width
});
},
/**
* Show an error message near this field
*
* @param {String} [value] - The user input text responsible for the error.
* Defaults to the `options.errorMessage` provided in construction.
*/
showErrorMessage: function showErrorMessage(value) {
var $container = this.$container.parent(".field-group"); // aui container
this.hideErrorMessage(); // remove old
this.$errorMessage.text(formatter.format(this.options.errorMessage, value || this.getQueryVal()));
if ($container.length === 1) {
$container.append(this.$errorMessage);
return;
}
if ($container.length === 0) {
$container = this.$container.parent(".frother-control-renderer"); // not in aui but JIRA renderer
}
if ($container.length === 1) {
this.$errorMessage.prependTo($container);
return;
}
if ($container.length === 0) {
this.$container.parent().append(this.$errorMessage);
}
},
/**
* Hides the error message
*/
hideErrorMessage: function hideErrorMessage() {
if (this.$errorMessage) {
this.$errorMessage.remove();
}
this.$container.parent().find(".error").remove(); // remove all error message from server also
},
/**
* Gets default options
*
* @private
* @return {Object}
*/
_getDefaultOptions: function _getDefaultOptions() {
CONTROL_ID += 1;
return {
id: CONTROL_ID,
// keyInputPeriod: expected milliseconds between consecutive keystrokes
// If this user types faster than this, no requests will be issued until they slow down.
keyInputPeriod: 75,
// localListLiveUpdateLimit: Won't search for new options if there are more options than this value
localListLiveUpdateLimit: 25,
// Only search for new options locally after this delay.
localListLiveUpdateDelay: 150
};
},
/**
* Appends furniture around specified dropdown element. This includes:
*
*
* - Field - text field used fro querying
* - Container - Wrapper used to contain all furniture
* - Dropdown Icon - Button in right of field used to open dropdown via mouse
*
*/
_createFurniture: function _createFurniture() {
this.$container = this._render("container").insertBefore(this.options.element);
this.suggestionsContainerId = this.options.id + SUGGESTIONS_ID_SUFFIX;
this.$field = this._render("field").appendTo(this.$container);
this.$dropDownIcon = this._render("dropdownAndLoadingIcon", this._hasDropdownButton()).appendTo(this.$container);
if (this.options.overlabel) {
this.$overlabel = this._render("overlabel").insertBefore(this.$field);
this.$overlabel.overlabel();
}
},
/**
* Whether or not to display dropdown icon/button
*
* @protected
* @return {Boolean}
*/
_hasDropdownButton: function _hasDropdownButton() {
return this.options.showDropdownButton || this.options.ajaxOptions && this.options.ajaxOptions.minQueryLength === 0;
},
/**
* Assigns events to DOM nodes
*
* @protected
*/
_assignEventsToFurniture: function _assignEventsToFurniture() {
var instance = this;
this._assignEvents("ignoreBlurElement", this.dropdownController.$layer);
this._assignEvents("container", this.$container);
if (this._hasDropdownButton()) {
this._assignEvents("ignoreBlurElement", this.$dropDownIcon);
this._assignEvents("dropdownAndLoadingIcon", this.$dropDownIcon);
}
// if this control is created as the result of a keydown event then we do no want to catch keyup or keypress for a moment
setTimeout(function () {
instance._assignEvents("field", instance.$field);
instance._assignEvents("keys", instance.$field);
}, 15);
this.listController.bind("itemFocus", this._onItemFocus.bind(this));
},
/**
* Requests JSON formatted suggestions from specified resource. Resource is sepecified in the ajaxOptions object
* passed to the constructed during initialization.
*
* If the query option of ajaxOptions is set to true, an ajax request will be made for every keypress. Otherwise
* ajax request will be made only the first time the dropdown is shown.
*
* @private
* @param {Boolean} force - flag to specify that gating by keyInputPeriod should be circumvented
*/
requestSuggestions: function requestSuggestions(force) {
var instance = this;
var deferred = new Deferred();
this.outstandingRequest = this.suggestionsHandler.execute(this.getQueryVal(), force).done(function (descriptors, query) {
if (query === instance.getQueryVal()) {
deferred.resolve(descriptors, query);
}
});
if (this.outstandingRequest.state() !== "resolved") {
window.clearTimeout(this.loadingWait); // clear existing wait
// wait 150ms until we should throbber to avoid flickering while typing
this.loadingWait = window.setTimeout(function () {
if (instance.outstandingRequest.state() === "pending") {
instance.showLoading();
}
}, 150);
this.outstandingRequest.always(function () {
instance.hideLoading(); // make sure we always remove throbber
});
}
return deferred;
},
/**
* Show the loading indicator
* @return {*} this
*/
showLoading: function showLoading() {
this.$dropDownIcon.addClass("loading").removeClass("noloading");
if (!this.$dropDownIcon.data("spinner")) {
this.$dropDownIcon.spin();
}
this.$field.attr('aria-busy', 'true');
return this;
},
/**
* Hide the loading indicator
* @return {*} this
*/
hideLoading: function hideLoading() {
this.$dropDownIcon.removeClass("loading").addClass("noloading");
this.$dropDownIcon.spinStop();
this.$field.attr('aria-busy', 'false');
return this;
},
/**
*
* Sets suggestions and shows them
*
* @param {Array} Descriptors
*/
_setSuggestions: function _setSuggestions(data) {
if (data) {
this.listController.generateListFromJSON(data, this.getQueryVal());
this.showSuggestions();
} else {
this.hideSuggestions();
}
// Makes WebDriver wait for the correct suggestions
this.$container.attr("data-query", this.getQueryVal());
},
/**
* Fades out & disables interactions with field
*/
disable: function disable() {
if (!this.disabled) {
this.$container.addClass("aui-disabled");
// The disabledBlanket is necessary to prevent clicks on other elements positioned over the field.
this.$disabledBlanket = this._render("disabledBlanket").appendTo(this.$container);
this.$field.attr('disabled', true);
this.dropdownController.hide();
this.disabled = true;
}
},
/**
* Enables interactions with field
*/
enable: function enable() {
if (this.disabled) {
this.$container.removeClass("aui-disabled");
this.$disabledBlanket.remove();
this.$field.attr('disabled', false);
this.disabled = false;
}
},
/**
* Gets input field value
*
* @return {String}
*/
getQueryVal: function getQueryVal() {
return jQuery.trim(this.$field.val());
},
_isValidInput: function _isValidInput(event) {
return this.$field.is(":visible") && !(event.type === "aui:keydown" && this.INVALID_KEYS[event.key]);
},
/**
* Hides list if the is no value in input, otherwise shows and resets suggestions in dropdown
*
* @param {Boolean} force - flag to specify that gating by keyInputPeriod (via requestSuggestions) should be circumvented
* @private
*/
_handleCharacterInput: function _handleCharacterInput(force) {
var queryLength = this.getQueryVal().length;
if (queryLength >= 1 || force) {
this.requestSuggestions(force).done(_.bind(function (suggestions) {
this._setSuggestions(suggestions);
}, this));
} else {
this.hideSuggestions();
}
},
/**
* Handles down key
*
* @param {Event} e
*/
_handleDown: function _handleDown(e) {
if (!this.suggestionsVisible) {
this.listController._latestQuery = ""; // JRADEV-9009 Resetting query value
this._handleCharacterInput(true);
}
e.preventDefault();
},
/**
* Cancels and pending or outstanding requests
*
* @protected
*/
_rejectPendingRequests: function _rejectPendingRequests() {
if (this.outstandingRequest) {
this.outstandingRequest.reject();
}
},
showSuggestions: function showSuggestions() {
if (this.suggestionsVisible) {
return;
}
this.suggestionsVisible = true;
this.dropdownController.show();
this.dropdownController.setWidth(this.$field.innerWidth());
this.dropdownController.setPosition();
this.listController.enable();
this.$field.attr("aria-expanded", "true");
this.$field.attr("aria-controls", this.suggestionsContainerId);
},
/**
* Hides suggestions
*/
hideSuggestions: function hideSuggestions() {
if (!this.suggestionsVisible) {
return;
}
this._rejectPendingRequests();
this.suggestionsVisible = false;
this.$dropDownIcon.addClass("noloading");
this.dropdownController.hide();
this.listController.disable();
this.$field.removeAttr("aria-activedescendant");
this.$field.attr("aria-expanded", "false");
this.$field.removeAttr("aria-controls");
},
_deactivate: function _deactivate() {
this.hideSuggestions();
},
/**
* Handles Escape key
*
* @param {Event} e
*/
_handleEscape: function _handleEscape(e) {
if (this.suggestionsVisible) {
e.stopPropagation();
if (e.type === "keyup") {
this.hideSuggestions();
if (Navigator.isIE() && Navigator.majorVersion() < 12) {
// IE - field has already received the event and lost focus (default browser behaviour)
this.$field.focus();
}
}
}
},
/**
* Selects currently focused suggestion, if there is one
*/
acceptFocusedSuggestion: function acceptFocusedSuggestion() {
var focused = this.listController.getFocused();
if (focused.length !== 0 && focused.is(":visible")) {
this.listController._acceptSuggestion(focused);
}
},
keys: {
"Down": function Down(e) {
if (this._hasDropdownButton()) {
this._handleDown(e);
}
},
"Up": function Up(e) {
e.preventDefault();
},
"Return": function Return(e) {
e.preventDefault();
}
},
onEdit: function onEdit() {
this._handleCharacterInput();
},
_events: {
dropdownAndLoadingIcon: {
click: function click(e) {
if (this.suggestionsVisible) {
this.hideSuggestions();
} else {
this._handleDown(e);
this.$field.focus();
}
e.stopPropagation();
}
},
container: {
disable: function disable() {
this.disable();
},
enable: function enable() {
this.enable();
}
},
field: {
blur: function blur() {
if (!this.ignoreBlurEvent) {
this._deactivate();
} else {
this.ignoreBlurEvent = false;
}
},
click: function click(e) {
e.stopPropagation();
},
"keydown keyup": function keydownKeyup(e) {
if (e.keyCode === keyCodes.ESCAPE) {
this._handleEscape(e);
}
}
},
keys: {
"aui:keydown input": function auiKeydownInput(event) {
this._handleKeyEvent(event);
}
},
ignoreBlurElement: {
mousedown: function mousedown(e) {
if (Navigator.isIE() && Navigator.majorVersion() < 12) {
// JRA-27685. IE fires blur events when user clicks on the scrollbar inside autocomplete suggestion list.
// In that case we don't deactivate the input field by setting a flag and checking it in field:blur.
// eslint-disable-next-line eqeqeq
var targetIsDropdownController = jQuery(e.target)[0] == jQuery(this.dropdownController.$layer)[0];
if (targetIsDropdownController) {
this.ignoreBlurEvent = true;
}
}
// IE9 and other browsers
e.preventDefault();
}
}
},
_renders: {
disabledBlanket: function disabledBlanket() {
// do not cause the extra force reflow by obtaining the outerHeight of a field in this place
// if really needed, it should be obtained earlier and cached
// for now, ADGS select fields are 30px tall so just using the constant
// sidenote: the usage of extra, external icons rendered over a select field
// should be deprecated and removed so that such blanket wouldn't be needed at all
var SELECT_FIELD_HEIGHT = 30;
return jQuery("").height(SELECT_FIELD_HEIGHT);
},
overlabel: function overlabel() {
return jQuery("").text(this.options.overlabel).attr({
"data-target": this.options.id + "-field",
"id": this.options.id + "-overlabel"
});
},
baseField: function baseField(tagName) {
// we create the element this way so there's no accidental XSS, like
// there might be if allowing jQuery to interpret tagName as HTML.
var el = document.createElement(tagName || "input");
return jQuery(el).attr({
"autocomplete": "off",
"role": "combobox",
"aria-autocomplete": "list",
"aria-haspopup": "true",
"aria-expanded": "false"
});
},
field: function field() {
return this._render("baseField").attr({
"class": "text",
"id": this.options.id + "-field",
"type": "text"
});
},
container: function container() {
return jQuery("").attr("id", this.options.id + "-queryable-container");
},
dropdownAndLoadingIcon: function dropdownAndLoadingIcon(showDropdown) {
return jQuery('More').toggleClass("drop-menu", !!showDropdown);
},
suggestionsContainer: function suggestionsContainer(idPrefix) {
return jQuery('').attr({
"class": "aui-list",
"id": idPrefix + SUGGESTIONS_ID_SUFFIX,
"tabindex": "-1",
"role": "listbox"
});
}
}
});
});
/** Preserve legacy namespace
@deprecated AJS.QueryableDropdown */
AJS.namespace('AJS.QueryableDropdown', null, require('jira/ajs/select/queryable-dropdown-select'));
AJS.namespace('AJS.QueryableDropdownSelect', null, require('jira/ajs/select/queryable-dropdown-select'));