/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
define([
'jquery',
'mage/template',
'mage/mage',
'jquery/ui',
'mage/backend/menu',
'mage/translate'
], function ($, mageTemplate) {
'use strict';
/**
* Implement base functionality
*/
$.widget('mage.suggest', {
widgetEventPrefix: 'suggest',
options: {
template: '<% if (data.items.length) { %>' +
'<% if (!data.term && !data.allShown() && data.recentShown()) { %>' +
'<h5 class="title"><%- data.recentTitle %></h5>' +
'<% } %>' +
'<ul data-mage-init=\'{"menu":[]}\'>' +
'<% _.each(data.items, function(value){ %>' +
'<% if (!data.itemSelected(value)) { %><li <%= data.optionData(value) %>>' +
'<a href="#"><%- value.label %></a></li><% } %>' +
'<% }); %>' +
'<% if (!data.term && !data.allShown() && data.recentShown()) { %>' +
'<li data-mage-init=\'{"actionLink":{"event":"showAll"}}\' class="show-all">' +
'<a href="#"><%- data.showAllTitle %></a></li>' +
'<% } %>' +
'</ul><% } else { %><span class="mage-suggest-no-records"><%- data.noRecordsText %></span><% } %>',
minLength: 1,
/**
* @type {(String|Array)}
*/
source: null,
delay: 500,
loadingClass: 'mage-suggest-state-loading',
events: {},
appendMethod: 'after',
controls: {
selector: ':ui-menu, :mage-menu',
eventsMap: {
focus: ['menufocus'],
blur: ['menublur'],
select: ['menuselect']
}
},
termAjaxArgument: 'label_part',
filterProperty: 'label',
className: null,
inputWrapper: '<div class="mage-suggest"><div class="mage-suggest-inner"></div></div>',
dropdownWrapper: '<div class="mage-suggest-dropdown"></div>',
preventClickPropagation: true,
currentlySelected: null,
submitInputOnEnter: true
},
/**
* Component's constructor
* @private
*/
_create: function () {
this._term = null;
this._nonSelectedItem = {
id: '',
label: ''
};
this.templates = {};
this._renderedContext = null;
this._selectedItem = this._nonSelectedItem;
this._control = this.options.controls || {};
this._setTemplate();
this._prepareValueField();
this._render();
this._bind();
},
/**
* Render base elements for suggest component
* @private
*/
_render: function () {
var wrapper;
this.dropdown = $(this.options.dropdownWrapper).hide();
wrapper = this.options.className ?
$(this.options.inputWrapper).addClass(this.options.className) :
$(this.options.inputWrapper);
this.element
.wrap(wrapper)[this.options.appendMethod](this.dropdown)
.attr('autocomplete', 'off');
},
/**
* Define a field for storing item id (find in DOM or create a new one)
* @private
*/
_prepareValueField: function () {
if (this.options.valueField) {
this.valueField = $(this.options.valueField);
} else {
this.valueField = this._createValueField()
.insertBefore(this.element)
.attr('name', this.element.attr('name'));
this.element.removeAttr('name');
}
},
/**
* Create value field which keeps a id for selected option
* can be overridden in descendants
* @return {jQuery}
* @private
*/
_createValueField: function () {
return $('<input/>', {
type: 'hidden'
});
},
/**
* Component's destructor
* @private
*/
_destroy: function () {
this.element
.unwrap()
.removeAttr('autocomplete');
if (!this.options.valueField) {
this.element.attr('name', this.valueField.attr('name'));
this.valueField.remove();
}
this.dropdown.remove();
this._off(this.element, 'keydown keyup blur');
},
/**
* Return actual value of an "input"-element
* @return {String}
* @private
*/
_value: function () {
return this.element[this.element.is(':input') ? 'val' : 'text']().trim();
},
/**
* Pass original event to a control component for handling it as it's own event
* @param {Object} event - event object
* @private
*/
_proxyEvents: function (event) {
var fakeEvent = $.extend({}, $.Event(event.type), {
ctrlKey: event.ctrlKey,
keyCode: event.keyCode,
which: event.keyCode
}),
target = this._control.selector ? this.dropdown.find(this._control.selector) : this.dropdown;
target.trigger(fakeEvent);
},
/**
* Bind handlers on specific events
* @private
*/
_bind: function () {
this._on($.extend({
/**
* @param {jQuery.Event} event
*/
keydown: function (event) {
var keyCode = $.ui.keyCode,
suggestList,
hasSuggestedItems,
hasSelectedItems,
selectedItem;
switch (event.keyCode) {
case keyCode.PAGE_UP:
case keyCode.UP:
if (!event.shiftKey) {
event.preventDefault();
this._proxyEvents(event);
}
suggestList = event.currentTarget.parentNode.getElementsByTagName('ul')[0];
hasSuggestedItems = event.currentTarget
.parentNode.getElementsByTagName('ul')[0].children.length >= 0;
if (hasSuggestedItems) {
selectedItem = $(suggestList.getElementsByClassName('_active')[0])
.removeClass('_active').prev().addClass('_active');
event.currentTarget.value = selectedItem.find('a').text();
}
break;
case keyCode.PAGE_DOWN:
case keyCode.DOWN:
if (!event.shiftKey) {
event.preventDefault();
this._proxyEvents(event);
}
suggestList = event.currentTarget.parentNode.getElementsByTagName('ul')[0];
hasSuggestedItems = event.currentTarget
.parentNode.getElementsByTagName('ul')[0].children.length >= 0;
if (hasSuggestedItems) {
hasSelectedItems = suggestList.getElementsByClassName('_active').length === 0;
if (hasSelectedItems) { //eslint-disable-line max-depth
selectedItem = $(suggestList.children[0]).addClass('_active');
event.currentTarget.value = selectedItem.find('a').text();
} else {
selectedItem = $(suggestList.getElementsByClassName('_active')[0])
.removeClass('_active').next().addClass('_active');
event.currentTarget.value = selectedItem.find('a').text();
}
}
break;
case keyCode.TAB:
if (this.isDropdownShown()) {
this._onSelectItem(event, null);
event.preventDefault();
}
break;
case keyCode.ENTER:
case keyCode.NUMPAD_ENTER:
this._toggleEnter(event);
if (this.isDropdownShown() && this._focused) {
this._proxyEvents(event);
event.preventDefault();
}
break;
case keyCode.ESCAPE:
if (this.isDropdownShown()) {
event.stopPropagation();
}
this.close(event);
this._blurItem();
break;
}
},
/**
* @param {jQuery.Event} event
*/
keyup: function (event) {
var keyCode = $.ui.keyCode;
switch (event.keyCode) {
case keyCode.HOME:
case keyCode.END:
case keyCode.PAGE_UP:
case keyCode.PAGE_DOWN:
case keyCode.ESCAPE:
case keyCode.UP:
case keyCode.DOWN:
case keyCode.LEFT:
case keyCode.RIGHT:
case keyCode.TAB:
break;
case keyCode.ENTER:
case keyCode.NUMPAD_ENTER:
if (this.isDropdownShown()) {
event.preventDefault();
}
break;
default:
this.search(event);
}
},
/**
* @param {jQuery.Event} event
*/
blur: function (event) {
if (!this.preventBlur) {
this._abortSearch();
this.close(event);
this._change(event);
} else {
this.element.trigger('focus');
}
},
cut: this.search,
paste: this.search,
input: this.search,
selectItem: this._onSelectItem,
click: this.search
}, this.options.events));
this._bindSubmit();
this._bindDropdown();
},
/**
* @param {Object} event
* @private
*/
_toggleEnter: function (event) {
var suggestList,
activeItems,
selectedItem;
if (!this.options.submitInputOnEnter) {
event.preventDefault();
}
suggestList = $(event.currentTarget.parentNode).find('ul').first();
activeItems = suggestList.find('._active');
if (activeItems.length >= 0) {
selectedItem = activeItems.first();
if (selectedItem.find('a') && selectedItem.find('a').attr('href') !== undefined) {
window.location = selectedItem.find('a').attr('href');
event.preventDefault();
}
}
},
/**
* Bind handlers for submit on enter
* @private
*/
_bindSubmit: function () {
this.element.parents('form').on('submit', function (event) {
if (!this.submitInputOnEnter) {
event.preventDefault();
}
});
},
/**
* @param {Object} e - event object
* @private
*/
_change: function (e) {
if (this._term !== this._value()) {
this._trigger('change', e);
}
},
/**
* Bind handlers for dropdown element on specific events
* @private
*/
_bindDropdown: function () {
var events = {
/**
* @param {jQuery.Event} e
*/
click: function (e) {
// prevent default browser's behavior of changing location by anchor href
e.preventDefault();
},
/**
* @param {jQuery.Event} e
*/
mousedown: function (e) {
e.preventDefault();
}
};
$.each(this._control.eventsMap, $.proxy(function (suggestEvent, controlEvents) {
$.each(controlEvents, $.proxy(function (i, handlerName) {
switch (suggestEvent) {
case 'select':
events[handlerName] = this._onSelectItem;
break;
case 'focus':
events[handlerName] = this._focusItem;
break;
case 'blur':
events[handlerName] = this._blurItem;
break;
}
}, this));
}, this));
if (this.options.preventClickPropagation) {
this._on(this.dropdown, events);
}
// Fix for IE 8
this._on(this.dropdown, {
/**
* Mousedown.
*/
mousedown: function () {
this.preventBlur = true;
},
/**
* Mouseup.
*/
mouseup: function () {
this.preventBlur = false;
}
});
},
/**
* @override
*/
_trigger: function (type, event) {
var result = this._superApply(arguments);
if (result === false && event) {
event.stopImmediatePropagation();
event.preventDefault();
}
return result;
},
/**
* Handle focus event of options item
* @param {Object} e - event object
* @param {Object} ui - object that can contain information about focused item
* @private
*/
_focusItem: function (e, ui) {
if (ui && ui.item) {
this._focused = $(ui.item).prop('tagName') ?
this._readItemData(ui.item) :
ui.item;
this.element.val(this._focused.label);
this._trigger('focus', e, {
item: this._focused
});
}
},
/**
* Handle blur event of options item
* @private
*/
_blurItem: function () {
this._focused = null;
this.element.val(this._term);
},
/**
* @param {Object} e - event object
* @param {Object} item
* @private
*/
_onSelectItem: function (e, item) {
if (item && typeof item === 'object' && $(e.target).is(this.element)) {
this._focusItem(e, {
item: item
});
}
if (this._trigger('beforeselect', e || null, {
item: this._focused
}) === false) {
return;
}
this._selectItem(e);
this._blurItem();
this._trigger('select', e || null, {
item: this._selectedItem
});
},
/**
* Save selected item and hide dropdown
* @private
* @param {Object} e - event object
*/
_selectItem: function (e) {
if (this._focused) {
this._selectedItem = this._focused;
if (this._selectedItem !== this._nonSelectedItem) {
this._term = this._selectedItem.label;
this.valueField.val(this._selectedItem.id);
this.close(e);
}
}
},
/**
* Read option data from item element
* @param {Element} element
* @return {Object}
* @private
*/
_readItemData: function (element) {
return element.data('suggestOption') || this._nonSelectedItem;
},
/**
* Check if dropdown is shown
* @return {Boolean}
*/
isDropdownShown: function () {
return this.dropdown.is(':visible');
},
/**
* Open dropdown
* @private
* @param {Object} e - event object
*/
open: function (e) {
if (!this.isDropdownShown()) {
this.element.addClass('_suggest-dropdown-open');
this.dropdown.show();
this._trigger('open', e);
}
},
/**
* Close and clear dropdown content
* @private
* @param {Object} e - event object
*/
close: function (e) {
this._renderedContext = null;
if (this.dropdown.length) {
this.element.removeClass('_suggest-dropdown-open');
this.dropdown.hide().empty();
}
this._trigger('close', e);
},
/**
* Acquire content template
* @private
*/
_setTemplate: function () {
this.templateName = 'suggest' + Math.random().toString(36).substr(2);
this.templates[this.templateName] = mageTemplate(this.options.template);
},
/**
* Execute search process
* @public
* @param {Object} e - event object
*/
search: function (e) {
var term = this._value();
if ((this._term !== term || term.length === 0) && !this.preventBlur) {
this._term = term;
if (typeof term === 'string' && term.length >= this.options.minLength) {
if (this._trigger('search', e) === false) { //eslint-disable-line max-depth
return;
}
this._search(e, term, {});
} else {
this._selectedItem = this._nonSelectedItem;
this._resetSuggestValue();
}
}
},
/**
* Clear suggest hidden input
* @private
*/
_resetSuggestValue: function () {
this.valueField.val(this._nonSelectedItem.id);
},
/**
* Actual search method, can be overridden in descendants
* @param {Object} e - event object
* @param {String} term - search phrase
* @param {Object} context - search context
* @private
*/
_search: function (e, term, context) {
var response = $.proxy(function (items) {
return this._processResponse(e, items, context || {});
}, this);
this.element.addClass(this.options.loadingClass);
if (this.options.delay) {
if (typeof this.options.data !== 'undefined') {
response(this.filter(this.options.data, term));
}
clearTimeout(this._searchTimeout);
this._searchTimeout = this._delay(function () {
this._source(term, response);
}, this.options.delay);
} else {
this._source(term, response);
}
},
/**
* Extend basic context with additional data (search results, search term)
* @param {Object} context
* @return {Object}
* @private
*/
_prepareDropdownContext: function (context) {
return $.extend(context, {
items: this._items,
term: this._term,
/**
* @param {Object} item
* @return {String}
*/
optionData: function (item) {
return 'data-suggest-option="' +
$('<div>').text(JSON.stringify(item)).html().replace(/"/g, '"') + '"';
},
itemSelected: $.proxy(this._isItemSelected, this),
noRecordsText: $.mage.__('No records found.')
});
},
/**
* @param {Object} item
* @return {Boolean}
* @private
*/
_isItemSelected: function (item) {
return item.id == (this._selectedItem && this._selectedItem.id ? //eslint-disable-line eqeqeq
this._selectedItem.id :
this.options.currentlySelected);
},
/**
* Render content of suggest's dropdown
* @param {Object} e - event object
* @param {Array} items - list of label+id objects
* @param {Object} context - template's context
* @private
*/
_renderDropdown: function (e, items, context) {
var tmpl = this.templates[this.templateName];
this._items = items;
tmpl = tmpl({
data: this._prepareDropdownContext(context)
});
$(tmpl).appendTo(this.dropdown.empty());
this.dropdown.trigger('contentUpdated')
.find(this._control.selector).on('focus', function (event) {
event.preventDefault();
});
this._renderedContext = context;
this.element.removeClass(this.options.loadingClass);
this.open(e);
},
/**
* @param {Object} e
* @param {Object} items
* @param {Object} context
* @private
*/
_processResponse: function (e, items, context) {
var renderer = $.proxy(function (i) {
return this._renderDropdown(e, i, context || {});
}, this);
if (this._trigger('response', e, [items, renderer]) === false) {
return;
}
this._renderDropdown(e, items, context);
},
/**
* Implement search process via spesific source
* @param {String} term - search phrase
* @param {Function} response - search results handler, process search result
* @private
*/
_source: function (term, response) {
var o = this.options,
ajaxData;
if (Array.isArray(o.source)) {
response(this.filter(o.source, term));
} else if (typeof o.source === 'string') {
ajaxData = {};
ajaxData[this.options.termAjaxArgument] = term;
this._xhr = $.ajax($.extend(true, {
url: o.source,
type: 'POST',
dataType: 'json',
data: ajaxData,
success: $.proxy(function (items) {
this.options.data = items;
response.apply(response, arguments);
}, this)
}, o.ajaxOptions || {}));
} else if (typeof o.source === 'function') {
o.source.apply(o.source, arguments);
}
},
/**
* Abort search process
* @private
*/
_abortSearch: function () {
this.element.removeClass(this.options.loadingClass);
clearTimeout(this._searchTimeout);
},
/**
* Perform filtering in advance loaded items and returns search result
* @param {Array} items - all available items
* @param {String} term - search phrase
* @return {Object}
*/
filter: function (items, term) {
var matcher = new RegExp(term.replace(/[\-\/\\\^$*+?.()|\[\]{}]/g, '\\$&'), 'i'),
itemsArray = Array.isArray(items) ? items : $.map(items, function (element) {
return element;
}),
property = this.options.filterProperty;
return $.grep(
itemsArray,
function (value) {
return matcher.test(value[property] || value.id || value);
}
);
}
});
/**
* Implement show all functionality and storing and display recent searches
*/
$.widget('mage.suggest', $.mage.suggest, {
options: {
showRecent: false,
showAll: false,
storageKey: 'suggest',
storageLimit: 10
},
/**
* @override
*/
_create: function () {
var recentItems;
if (this.options.showRecent && window.localStorage) {
recentItems = JSON.parse(localStorage.getItem(this.options.storageKey));
/**
* @type {Array} - list of recently searched items
* @private
*/
this._recentItems = Array.isArray(recentItems) ? recentItems : [];
}
this._super();
},
/**
* @override
*/
_bind: function () {
this._super();
this._on(this.dropdown, {
/**
* @param {jQuery.Event} e
*/
showAll: function (e) {
e.stopImmediatePropagation();
e.preventDefault();
this.element.trigger('showAll');
}
});
if (this.options.showRecent || this.options.showAll) {
this._on({
/**
* @param {jQuery.Event} e
*/
focus: function (e) {
if (!this.isDropdownShown()) {
this.search(e);
}
},
showAll: this._showAll
});
}
},
/**
* @private
* @param {Object} e - event object
*/
_showAll: function (e) {
this._abortSearch();
this._search(e, '', {
_allShown: true
});
},
/**
* @override
*/
search: function (e) {
if (!this._value()) {
if (this.options.showRecent) {
if (this._recentItems.length) { //eslint-disable-line max-depth
this._processResponse(e, this._recentItems, {});
} else {
this._showAll(e);
}
} else if (this.options.showAll) {
this._showAll(e);
}
}
this._superApply(arguments);
},
/**
* @override
*/
_selectItem: function () {
this._superApply(arguments);
if (this._selectedItem && this._selectedItem.id && this.options.showRecent) {
this._addRecent(this._selectedItem);
}
},
/**
* @override
*/
_prepareDropdownContext: function () {
var context = this._superApply(arguments);
return $.extend(context, {
recentShown: $.proxy(function () {
return this.options.showRecent;
}, this),
recentTitle: $.mage.__('Recent items'),
showAllTitle: $.mage.__('Show all...'),
/**
* @return {Boolean}
*/
allShown: function () {
return !!context._allShown;
}
});
},
/**
* Add selected item of search result into storage of recents
* @param {Object} item - label+id object
* @private
*/
_addRecent: function (item) {
this._recentItems = $.grep(this._recentItems, function (obj) {
return obj.id !== item.id;
});
this._recentItems.unshift(item);
this._recentItems = this._recentItems.slice(0, this.options.storageLimit);
localStorage.setItem(this.options.storageKey, JSON.stringify(this._recentItems));
}
});
/**
* Implement multi suggest functionality
*/
$.widget('mage.suggest', $.mage.suggest, {
options: {
multiSuggestWrapper: '<ul class="mage-suggest-choices">' +
'<li class="mage-suggest-search-field" data-role="parent-choice-element"><' +
'label class="mage-suggest-search-label"></label></li></ul>',
choiceTemplate: '<li class="mage-suggest-choice button"><div><%- text %></div>' +
'<span class="mage-suggest-choice-close" tabindex="-1" ' +
'data-mage-init=\'{"actionLink":{"event":"removeOption"}}\'></span></li>',
selectedClass: 'mage-suggest-selected'
},
/**
* @override
*/
_create: function () {
this.choiceTmpl = mageTemplate(this.options.choiceTemplate);
this._super();
if (this.options.multiselect) {
this.valueField.hide();
}
},
/**
* @override
*/
_render: function () {
this._super();
if (this.options.multiselect) {
this._renderMultiselect();
}
},
/**
* Render selected options
* @private
*/
_renderMultiselect: function () {
var that = this;
this.element.wrap(this.options.multiSuggestWrapper);
this.elementWrapper = this.element.closest('[data-role="parent-choice-element"]');
$(function () {
that._getOptions()
.each(function (i, option) {
option = $(option);
that._createOption({
id: option.val(),
label: option.text()
});
});
});
},
/**
* @return {Array} array of DOM-elements
* @private
*/
_getOptions: function () {
return this.valueField.find('option');
},
/**
* @override
*/
_bind: function () {
this._super();
if (this.options.multiselect) {
this._on({
/**
* @param {jQuery.Event} event
*/
keydown: function (event) {
if (event.keyCode === $.ui.keyCode.BACKSPACE) {
if (!this._value()) {
this._removeLastAdded(event);
}
}
},
removeOption: this.removeOption
});
}
},
/**
* @param {Array} items
* @return {Array}
* @private
*/
_filterSelected: function (items) {
var options = this._getOptions();
return $.grep(items, function (value) {
var itemSelected = false;
$.each(options, function () {
if (value.id == $(this).val()) { //eslint-disable-line eqeqeq
itemSelected = true;
}
});
return !itemSelected;
});
},
/**
* @override
*/
_processResponse: function (e, items, context) {
if (this.options.multiselect) {
items = this._filterSelected(items, context);
}
this._superApply([e, items, context]);
},
/**
* @override
*/
_prepareValueField: function () {
this._super();
if (this.options.multiselect && !this.options.valueField && this.options.selectedItems) {
$.each(this.options.selectedItems, $.proxy(function (i, item) {
this._addOption(item);
}, this));
}
},
/**
* If "multiselect" option is set, then do not need to clear value for hidden select, to avoid losing of
* previously selected items
* @override
*/
_resetSuggestValue: function () {
if (!this.options.multiselect) {
this._super();
}
},
/**
* @override
*/
_createValueField: function () {
if (this.options.multiselect) {
return $('<select></select>', {
type: 'hidden',
multiple: 'multiple'
});
}
return this._super();
},
/**
* @override
*/
_selectItem: function (e) {
if (this.options.multiselect) {
if (this._focused) {
this._selectedItem = this._focused;
/* eslint-disable max-depth */
if (this._selectedItem !== this._nonSelectedItem) {
this._term = '';
this.element.val(this._term);
if (this._isItemSelected(this._selectedItem)) {
$(e.target).removeClass(this.options.selectedClass);
this.removeOption(e, this._selectedItem);
this._selectedItem = this._nonSelectedItem;
} else {
$(e.target).addClass(this.options.selectedClass);
this._addOption(e, this._selectedItem);
}
}
/* eslint-enable max-depth */
}
this.close(e);
} else {
this._superApply(arguments);
}
},
/**
* @override
*/
_isItemSelected: function (item) {
if (this.options.multiselect) {
return this.valueField.find('option[value=' + item.id + ']').length > 0;
}
return this._superApply(arguments);
},
/**
*
* @param {Object} item
* @return {Element}
* @private
*/
_createOption: function (item) {
var option = this._getOption(item);
if (!option.length) {
option = $('<option>', {
value: item.id,
selected: true
}).text(item.label);
}
return option.data('renderedOption', this._renderOption(item));
},
/**
* Add selected item in to select options
* @param {Object} e - event object
* @param {*} item
* @private
*/
_addOption: function (e, item) {
this.valueField.append(this._createOption(item).data('selectTarget', $(e.target)));
},
/**
* @param {Object|Element} item
* @return {Element}
* @private
*/
_getOption: function (item) {
return $(item).prop('tagName') ?
$(item) :
this.valueField.find('option[value=' + item.id + ']');
},
/**
* Remove last added option
* @private
* @param {Object} e - event object
*/
_removeLastAdded: function (e) {
var lastAdded = this._getOptions().last();
if (lastAdded.length) {
this.removeOption(e, lastAdded);
}
},
/**
* Remove item from select options
* @param {Object} e - event object
* @param {Object} item
* @private
*/
removeOption: function (e, item) {
var option = this._getOption(item),
selectTarget = option.data('selectTarget');
if (selectTarget && selectTarget.length) {
selectTarget.removeClass(this.options.selectedClass);
}
option.data('renderedOption').remove();
option.remove();
},
/**
* Render visual element of selected item
* @param {Object} item - selected item
* @private
*/
_renderOption: function (item) {
var tmpl = this.choiceTmpl({
text: item.label
});
return $(tmpl)
.insertBefore(this.elementWrapper)
.trigger('contentUpdated')
.on('removeOption', $.proxy(function (e) {
this.removeOption(e, item);
}, this));
}
});
return $.mage.suggest;
});
|