/**
* @file Search UI written in vanilla JavaScript. Menu structure for results. Filters are integrated as search results.
* @version {@link https://github.com/JohT/search-menu-ui/releases/latest latest version}
* @author JohT
*/
var module = datarestructorInternalCreateIfNotExists(module); // Fallback for vanilla js without modules
function datarestructorInternalCreateIfNotExists(objectToCheck) {
return objectToCheck || {};
}
/**
* Contains the main ui component of the search menu ui.
* @module searchmenu
*/
var searchmenu = module.exports={}; // Export module for npm...
searchmenu.internalCreateIfNotExists = datarestructorInternalCreateIfNotExists;
var eventtarget = eventtarget || require("./ponyfills/eventCurrentTargetPonyfill"); // supports vanilla js & npm
var selectionrange = selectionrange || require("./ponyfills/selectionRangePonyfill"); // supports vanilla js & npm
var eventlistener = eventlistener || require("./ponyfills/addEventListenerPonyfill"); // supports vanilla js & npm
/**
* @typedef {Object} module:searchmenu.SearchViewDescription Describes a part of the search view (e.g. search result details).
* @property {string} viewElementId id of the element (e.g. "div"), that contains the view with all list elements and their parent.
* @property {string} listParentElementId id of the element (e.g. "ul"), that contains all list entries and is located inside the view.
* @property {string} listEntryElementIdPrefix id prefix (followed by "--" and the index number) for every list entry
* @property {string} [listEntryElementTag="li"] element tag for list entries. defaults to "li".
* @property {string} [listEntryTextTemplate="{{displayName}}: {{value}}"] template for the text of each list entry
* @property {string} [listEntrySummaryTemplate="{{summaries[0].displayName}}: {{summaries[0].value}}"] template for the text of each list entry, if the data group "summary" exists.
* @property {string} [listEntryStyleClassTemplate="{{view.listEntryElementIdPrefix}} {{category}}"] template for the style class of each list entry.
* @property {boolean} [isSelectableFilterOption=false] Specifies, if the list entry can be selected as filter option
*/
searchmenu.SearchViewDescriptionBuilder = (function () {
"use strict";
/**
* Builds a {@link module:searchmenu.SearchViewDescription}, which describes a part of the search menu called "view".
* Examples for views are: results, details, filters, filter options. There might be more in future.
*
* The description contains the id's of the html elements, that will be used as "binding", to
* add elements like results. The "viewElementId" is the main parent (may be a "div" tag) of all view elements,
* that contains the "listParentElementId", which is the parent of the list entries (may be a "ul" tag).
*
* The text content of each entry is described by the text templates.
*
* Furthermore, the css style class can be given as a template,
* so search result field values can be used as a part of the style class.
*
* @param {module:searchmenu.SearchViewDescription} template optional parameter that contains a template to clone
* @constructs SearchViewDescriptionBuilder
* @alias module:searchmenu.SearchViewDescriptionBuilder
*/
function SearchViewDescription(template) {
var defaultTemplate = "{{displayName}}: {{value}}";
var defaultSummaryTemplate = "{{summaries[0].displayName}}: {{summaries[0].value}}";
var defaultStyleClassTemplate = "{{view.listEntryElementIdPrefix}} {{category}}";
var defaultTag = "li";
/**
* @type {module:searchmenu.SearchViewDescription}
* @protected
*/
this.description = {
viewElementId: template ? template.viewElementId : "",
listParentElementId: template ? template.listParentElementId : "",
listEntryElementIdPrefix: template ? template.listEntryElementIdPrefix : "",
listEntryElementTag: template ? template.listEntryElementTag : defaultTag,
listEntryTextTemplate: template ? template.listEntryTextTemplate : defaultTemplate,
listEntrySummaryTemplate: template ? template.listEntrySummaryTemplate : defaultSummaryTemplate,
listEntryStyleClassTemplate: template ? template.listEntryStyleClassTemplate : defaultStyleClassTemplate,
isSelectableFilterOption: template ? template.isSelectableFilterOption : false
};
/**
* ID of the element (e.g. "div"), that contains the view with all list elements and their parent.
*
* @param {string} value view element ID.
* @returns {module:searchmenu.SearchViewDescriptionBuilder}
*/
this.viewElementId = function (value) {
this.description.viewElementId = withDefault(value, "");
return this;
};
/**
* ID of the element (e.g. "ul"), that contains all list entries and is located inside the view.
* @param {string} value parent element ID
* @returns {module:searchmenu.SearchViewDescriptionBuilder}
*/
this.listParentElementId = function (value) {
this.description.listParentElementId = withDefault(value, "");
return this;
};
/**
* ID prefix (followed by "--" and the index number) for every list entry.
* @param {string} value ID prefix for every list entry element
* @returns {module:searchmenu.SearchViewDescriptionBuilder}
*/
this.listEntryElementIdPrefix = function (value) {
//TODO could be checked to not contain the index separation chars "--"
this.description.listEntryElementIdPrefix = withDefault(value, "");
return this;
};
/**
* Element tag for list entries.
* @param {string} [value="li"] tag for every list entry element
* @returns {module:searchmenu.SearchViewDescriptionBuilder}
*/
this.listEntryElementTag = function (value) {
this.description.listEntryElementTag = withDefault(value, defaultTag);
return this;
};
/**
* Template for the text of each list entry.
* May contain variables in double curly brackets.
*
* @param {string} [value="{{displayName}}: {{value}}"] list entry text template when there is no summary data group
* @returns {module:searchmenu.SearchViewDescriptionBuilder}
*/
this.listEntryTextTemplate = function (value) {
this.description.listEntryTextTemplate = withDefault(value, defaultTemplate);
return this;
};
/**
* Template for the text of each list entry, if the data group "summary" exists.
* May contain variables in double curly brackets.
*
* @param {string} [value="{{summaries[0].displayName}}: {{summaries[0].value}}"] list entry text template when there is a summary data group
* @returns {module:searchmenu.SearchViewDescriptionBuilder}
*/
this.listEntrySummaryTemplate = function (value) {
this.description.listEntrySummaryTemplate = withDefault(value, defaultSummaryTemplate);
return this;
};
/**
* Template for the style classes of each list entry.
* May contain variables in double curly brackets.
* To use the property values of this view, prefix them with "view", e.g.: "{{view.listEntryElementIdPrefix}}".
*
* @param {string} [value="{{view.listEntryElementIdPrefix}} {{category}}"] list entry style classes template
* @returns {module:searchmenu.SearchViewDescriptionBuilder}
*/
this.listEntryStyleClassTemplate = function (value) {
this.description.listEntryStyleClassTemplate = withDefault(value, defaultStyleClassTemplate);
return this;
};
/**
* Specifies, if the list entry can be selected as filter option.
* @param {boolean} [value=false] if a list entry is selectable as filter option
* @returns {module:searchmenu.SearchViewDescriptionBuilder}
*/
this.isSelectableFilterOption = function (value) {
this.description.isSelectableFilterOption = value === true;
return this;
};
/**
* Finishes the build of the description and returns its final (meant to be immutable) object.
* @returns {module:searchmenu.SearchViewDescription}
*/
this.build = function () {
return this.description;
};
}
function withDefault(value, defaultValue) {
return isSpecifiedString(value) ? value : defaultValue;
}
function isSpecifiedString(value) {
return typeof value === "string" && value != null && value != "";
}
return SearchViewDescription;
})();
//TODO could provide the currently only described SearchUiData as own data structure in its own module.
/**
* @typedef {Object} module:searchmenu.SearchUiData
* @property {String} [category=""] name of the category. Default = "". Could contain a short domain name. (e.g. "city")
* @property {String} fieldName field name that will be used e.g. as a search parameter name for filter options.
* @property {String} [displayName=""] readable display name for e.g. the list of results.
* @property {String} [abbreviation=""] one optional character, a symbol character or a short abbreviation of the category
* @property {String} value value of the field
* @property {module:searchmenu.SearchUiData[]} details if there are further details that will be displayed e.g. on mouse over
* @property {module:searchmenu.SearchUiData[]} options contains filter options that can be selected as search parameters
* @property {module:searchmenu.SearchUiData[]} default array with one element representing the default filter option (selected automatically)
* @property {module:searchmenu.SearchUiData[]} summaries fields that are used to display the main search entry/result
* @property {module:searchmenu.SearchUiData[]} urltemplate contains a single field with the value of the url template. Marks the entry as navigation target.
*/
/**
* @callback module:searchmenu.ResolveTemplateFunction replaces variables with object properties.
* @param {String} template may contain variables in double curly brackets. T
* Typically supported variables would be: {{category}} {{fieldName}}, {{displayName}}, {{abbreviation}}, {{value}}
* @return {String} string with resolved/replaced variables
*/
/**
* @callback module:searchmenu.FieldsJson returns the fields as JSON
* @return {String} JSON of all contained fields
*/
/**
* This function will be called, when search results are available.
* @callback SearchServiceResultAvailable
* @param {Object} searchResultData already parsed data object containing the result of the search
*/
/**
* This function will be called to trigger search (calling the search backend).
* @callback module:searchmenu.SearchService
* @param {Object} searchParameters object that contains all parameters as properties. It will be converted to JSON.
* @param {module:searchmenu.SearchServiceResultAvailable} onSearchResultsAvailable will be called when search results are available.
*/
/**
* This function converts the data from search backend to the structure needed by the search UI.
* @callback module:searchmenu.DataConverter
* @param {Object} searchData
* @returns {module:searchmenu.SearchUiData} converted and structured data for search UI
*/
/**
* This function replaces variables in double curly brackets with the property values of the given object.
* @callback module:searchmenu.TemplateResolver
* @param {String} templateToResolve may contain variables in double curly brackets e.g. like `"{{searchtext}}"`.
* @param {Object} sourceObject the fields of this object are used to replace the variables in the template
* @returns {module:searchmenu.SearchUiData} converted and structured data for search UI
*/
/**
* This function adds predefined search parameters before search is triggered, e.g. constants, environment parameters, ...
* @callback module:searchmenu.SearchParameterAdder
* @param {Object} searchParametersObject
*/
/**
* This function will be called when a new HTML is created.
* @callback module:searchmenu.ElementCreatedListener
* @param {Element} newlyCreatedElement
* @param {boolean} isParent true, if it is the created parent. false, if it is a child within the created parent.
*/
/**
* This function will be called to navigate to a selected search result url.
* @callback module:searchmenu.NavigateToFunction
* @param {String} destinationUrl
*/
/**
* @typedef {Object} module:searchmenu.SearchMenuConfig
* @property {module:searchmenu.SearchService} triggerSearch triggers search (backend)
* @property {module:searchmenu.DataConverter} convertData converts search result data to search ui data. Lets data through unchanged by default.
* @property {module:searchmenu.searchParameterAdder} addPredefinedParametersTo adds custom search parameters
* @property {module:searchmenu.ElementCreatedListener} onCreatedElement this function will be called when a new HTML is created.
* @property {module:searchmenu.NavigateToFunction} navigateTo this function will be called to navigate to a selected search result url.
* @property {string} searchAreaElementId id of the whole search area (default="searcharea")
* @property {string} inputElementId id of the search input field (default="searchinputtext")
* @property {module:searchmenu.SearchViewDescription} resultsView describes the main view containing the search results
* @property {module:searchmenu.SearchViewDescription} detailView describes the details view
* @property {module:searchmenu.SearchViewDescription} filterOptionsView describes the filter options view
* @property {module:searchmenu.SearchViewDescription} filtersView describes the filters view
* @property {string} [waitBeforeClose=700] timeout in milliseconds when search is closed after blur (loss of focus) (default=700)
* @property {string} [waitBeforeSearch=500] time in milliseconds to wait until typing is finished and search starts (default=500)
* @property {string} [waitBeforeMouseOver=700] time in milliseconds to wait until mouse over opens details (default=700)
*/
searchmenu.SearchMenuAPI = (function () {
"use strict";
/**
* Search Menu UI API
* @constructs SearchMenuAPI
* @alias module:searchmenu.SearchMenuAPI
*/
function SearchMenuApiBuilder() {
this.config = {
triggerSearch: function (/* searchParameters, onSearchResultsAvailable */) {
throw new Error("search service needs to be defined.");
},
convertData: function (sourceData) {
return sourceData;
},
resolveTemplate: function (/* sourceData */) {
throw new Error("template resolver needs to be defined.");
},
addPredefinedParametersTo: function (/* object */) {
//does nothing if not specified otherwise
},
onCreatedElement: function (/* element, isParent */) {
//does nothing if not specified otherwise
},
navigateTo: function (destinationUrl) {
window.location.href = destinationUrl;
},
createdElementListeners: [],
searchAreaElementId: "searcharea",
inputElementId: "searchinputtext",
searchTextParameterName: "searchtext",
resultsView: defaultResultsView(),
detailView: defaultDetailView(),
filterOptionsView: defaultFilterOptionsView(),
filtersView: defaultFiltersView(),
waitBeforeClose: 700,
waitBeforeSearch: 500,
waitBeforeMouseOver: 700
};
/**
* Defines the search service function, that will be called whenever search is triggered.
* @param {module:searchmenu.SearchService} service function that will be called to trigger search (backend).
* @returns module:searchmenu.SearchMenuAPI
*/
this.searchService = function (service) {
this.config.triggerSearch = service;
return this;
};
/**
* Defines the converter, that converts search result data to search ui data.
* Without setting a data converter, data is taken directly from the backend service,
* that needs to provide the results in the search menu data structure.
* @param {module:searchmenu.DataConverter} converter function that will be called to create the search menu data structure
* @returns module:searchmenu.SearchMenuAPI
*/
this.dataConverter = function (converter) {
this.config.convertData = converter;
return this;
};
/**
* Defines the template resolver, that replaces variables in double curly brackets with the property values of the given object.
* @param {module:searchmenu.TemplateResolver} resolver function that will be called to resolve strings with variables.
* @returns module:searchmenu.SearchMenuAPI
*/
this.templateResolver = function (resolver) {
this.config.resolveTemplate = resolver;
return this;
};
/**
* Defines the function, that adds predefined (fixed, constant, environmental) search parameters
* to the first parameter object.
* @param {module:searchmenu.SearchParameterAdder} adder function that will be called to before search is triggered.
* @returns module:searchmenu.SearchMenuAPI
*/
this.addPredefinedParametersTo = function (adder) {
this.config.addPredefinedParametersTo = adder;
return this;
};
/**
* Sets the listener, that will be called, when a new HTML element was created.
* @param {module:searchmenu.ElementCreatedListener} listener
* @returns module:searchmenu.SearchMenuAPI
*/
this.setElementCreatedHandler = function (listener) {
this.config.onCreatedElement = listener;
return this;
};
/**
* Adds another listener, that will be called, when a new HTML element was created.
* @param {module:searchmenu.ElementCreatedListener} listener
* @returns module:searchmenu.SearchMenuAPI
*/
this.addElementCreatedHandler = function (listener) {
this.config.createdElementListeners.push(listener);
return this;
};
/**
* Adds the given style class when an element receives focus.
* This is done for every element that is created dynamically (e.g. search results and filters).
* It is only meant to be used for browsers like old IE5 ones that doesn't support focus pseudo style class.
*
* @param {String} [focusStyleClassName="focus"]
* @returns module:searchmenu.SearchMenuAPI
*/
this.addFocusStyleClassOnEveryCreatedElement = function (focusStyleClassName) {
var className = withDefault(focusStyleClassName, "focus");
this.addElementCreatedHandler(function (element, isParent) {
if (!isParent) {
return;
}
addEvent("focus", element, function (event) {
addClass(className, getEventTarget(event));
});
addEvent("blur", element, function (event) {
removeClass(className, getEventTarget(event));
});
});
return this;
};
/**
* Sets the element ID of the parent, that represents the whole search menu component.
* @param {String} [id="searcharea"] id of the parent element, that represents the whole search menu component.
* @returns module:searchmenu.SearchMenuAPI
*/
this.searchAreaElementId = function (id) {
this.config.searchAreaElementId = withDefault(id, "searcharea");
return this;
};
/**
* Sets the input search text element ID,.
* @param {String} [id="searchinputtext"] id of the input element, that contains the search text.
* @returns module:searchmenu.SearchMenuAPI
*/
this.inputElementId = function (id) {
this.config.inputElementId = withDefault(id, "searchinputtext");
return this;
};
/**
* Sets the name of the backend search service parameter, that contains the input search text.
* @param {String} [value="searchtext"] name of the parameter, that contains the input search text and that can be used as a variable inside the url or body template for the backend service
* @returns module:searchmenu.SearchMenuAPI
*/
this.searchTextParameterName = function (value) {
this.config.searchTextParameterName = withDefault(value, "searchtext");
return this;
};
/**
* Sets the view, that is used to display all search results.
* The default view settings can be found [here]{@link module:searchmenu.SearchMenuAPI.defaultResultsView}.
*
* @param {module:searchmenu.SearchViewDescription} view connects the part of the search menu, that displays all search results
* @returns module:searchmenu.SearchMenuAPI
* @see {@link module:searchmenu.SearchMenuAPI.defaultResultsView}
*/
this.resultsView = function (view) {
this.config.resultsView = view;
return this;
};
/**
* Sets the view, that is used to display details of a selected search result.
* The default view settings can be found [here]{@link module:searchmenu.SearchMenuAPI.defaultDetailView}.
*
* @param {module:searchmenu.SearchViewDescription} view connects the part of the search menu, that displays details of a selected search result
* @returns module:searchmenu.SearchMenuAPI
* @see {@link module:searchmenu.SearchMenuAPI.defaultDetailView}
*/
this.detailView = function (view) {
this.config.detailView = view;
return this;
};
/**
* Sets the view, that is used to display currently selected filter options.
* The default view settings can be found [here]{@link module:searchmenu.SearchMenuAPI.defaultFilterOptionsView}.
*
* @param {module:searchmenu.SearchViewDescription} view connects the part of the search menu, that displays currently selected filter options
* @returns module:searchmenu.SearchMenuAPI
* @see {@link module:searchmenu.SearchMenuAPI.defaultFilterOptionsView}
*/
this.filterOptionsView = function (view) {
this.config.filterOptionsView = view;
return this;
};
/**
* Sets the view, that is used to display search results, that represent filter options.
* The default view settings can be found [here]{@link module:searchmenu.SearchMenuAPI.defaultFiltersView}.
*
* @param {module:searchmenu.SearchViewDescription} view connects the part of the search menu, that displays search results, that represent filter options
* @returns module:searchmenu.SearchMenuAPI
* @see {@link module:searchmenu.SearchMenuAPI.defaultFiltersView}
*/
this.filtersView = function (view) {
this.config.filtersView = view;
return this;
};
/**
* Sets the time the search menu will remain open, when it has lost focus.
* Prevents the menu to disappear while using it.
* @param {number} [ms=700] time in milliseconds the search menu will remain open until it is closed after loosing focus.
* @returns module:searchmenu.SearchMenuAPI
*/
this.waitBeforeClose = function (ms) {
this.config.waitBeforeClose = ms;
return this;
};
/**
* Sets the time to wait before the search service is called.
* Prevents calls to the search backend while changing the search input.
* @param {number} [ms=500] time in milliseconds to wait before the search service is called
* @returns module:searchmenu.SearchMenuAPI
*/
this.waitBeforeSearch = function (ms) {
this.config.waitBeforeSearch = ms;
return this;
};
/**
* Sets the time to wait before search result details are opened on mouse over.
* Doesn't affect keyboard selection, which will immediately open the search details.
* Prevents details to open on search results, that are only touched by the mouse pointer for a short period of time.
* @param {number} [ms=700] time in milliseconds to wait before search result details are opened on mouse over.
* @returns module:searchmenu.SearchMenuAPI
*/
this.waitBeforeMouseOver = function (ms) {
this.config.waitBeforeMouseOver = ms;
return this;
};
/**
* Finishes the configuration and creates the {@link module:searchmenu.SearchMenuUI}.
* @returns module:searchmenu.SearchMenuUI
*/
this.start = function () {
var config = this.config;
if (config.createdElementListeners.length > 0) {
this.setElementCreatedHandler(function (element, isParent) {
var index = 0;
for (index = 0; index < config.createdElementListeners.length; index += 1) {
config.createdElementListeners[index](element, isParent);
}
});
}
return new searchmenu.SearchMenuUI(config);
};
}
/**
* Contains the default settings for the results view.
* - viewElementId = "`searchresults`"
* - listParentElementId = "`searchmatches`"
* - listEntryElementIdPrefix = "`result`"
* - listEntryTextTemplate = "`{{abbreviation}} {{displayName}}`"
* - listEntrySummaryTemplate = "`{{summaries[0].abbreviation}} <b>{{summaries[1].value}}</b><br>{{summaries[2].value}}: {{summaries[0].value}}`"
*
* @returns {module:searchmenu.SearchViewDescription} default settings for the results view
* @protected
* @memberof module:searchmenu.SearchMenuAPI
*/
function defaultResultsView() {
return new searchmenu.SearchViewDescriptionBuilder()
.viewElementId("searchresults")
.listParentElementId("searchmatches")
.listEntryElementIdPrefix("result")
.listEntryTextTemplate("{{abbreviation}} {{displayName}}")
.listEntrySummaryTemplate(
"{{summaries[0].abbreviation}} <b>{{summaries[1].value}}</b><br>{{summaries[2].value}}: {{summaries[0].value}}"
)
.build();
}
/**
* Contains the default settings for the details view.
* - viewElementId = "`searchdetails`"
* - listParentElementId = "`searchdetailentries`"
* - listEntryElementIdPrefix = "`detail`"
* - listEntryTextTemplate = "`<b>{{displayName}}:</b> {{value}}`"
*
* @returns {module:searchmenu.SearchViewDescription} default settings for the details view
* @protected
* @memberof module:searchmenu.SearchMenuAPI
*/
function defaultDetailView() {
return new searchmenu.SearchViewDescriptionBuilder()
.viewElementId("searchdetails")
.listParentElementId("searchdetailentries")
.listEntryElementIdPrefix("detail")
.listEntryTextTemplate("<b>{{displayName}}:</b> {{value}}")
.build();
}
/**
* Contains the default settings for the filter options view.
* - viewElementId = "`searchfilteroptions`"
* - listParentElementId = "`searchfilteroptionentries`"
* - listEntryElementIdPrefix = "`filter`"
* - listEntryTextTemplate = "`{{value}}`"
* - listEntrySummaryTemplate = "`{{summaries[0].value}}`"
* - isSelectableFilterOption = `true`
*
* @returns {module:searchmenu.SearchViewDescription} default settings for the filter options view
* @protected
* @memberof module:searchmenu.SearchMenuAPI
*/
function defaultFilterOptionsView() {
return new searchmenu.SearchViewDescriptionBuilder()
.viewElementId("searchfilteroptions")
.listParentElementId("searchfilteroptionentries")
.listEntryElementIdPrefix("filter")
.listEntryTextTemplate("{{value}}")
.listEntrySummaryTemplate("{{summaries[0].value}}")
.isSelectableFilterOption(true)
.build();
}
/**
* Contains the default settings for the filters view.
* - viewElementId = "`searchresults`"
* - listParentElementId = "`searchfilters`"
* - listEntryElementIdPrefix = "`filter`"
* - isSelectableFilterOption = `true`
* @returns {module:searchmenu.SearchViewDescription} default settings for the filters view
* @protected
* @memberof module:searchmenu.SearchMenuAPI
*/
function defaultFiltersView() {
return new searchmenu.SearchViewDescriptionBuilder()
.viewElementId("searchresults")
.listParentElementId("searchfilters")
.listEntryElementIdPrefix("filter")
.isSelectableFilterOption(true)
.build();
}
function addEvent(eventName, element, eventHandler) {
eventlistener.addEventListener(eventName, element, eventHandler);
}
function getEventTarget(event) {
return eventtarget.getEventTarget(event);
}
function addClass(classToAdd, element) {
removeClass(classToAdd, element);
var separator = element.className.length > 0 ? " " : "";
element.className += separator + classToAdd;
}
function removeClass(classToRemove, element) {
var regex = new RegExp("\\s?\\b" + classToRemove + "\\b", "gi");
element.className = element.className.replace(regex, "");
}
function withDefault(value, defaultValue) {
return isSpecifiedString(value) ? value : defaultValue;
}
function isSpecifiedString(value) {
return typeof value === "string" && value != null && value != "";
}
return SearchMenuApiBuilder;
}());
searchmenu.SearchMenuUI = (function () {
"use strict";
/**
* Search Menu UI.
*
* Contains the "behavior" of the search bar. It submits the search query,
* parses the results, displays matches and filters and responds to
* clicks and key presses.
* Further resources:
* - [API]{@link module:searchmenu.SearchMenuAPI}
* - [Configuration]{@link module:searchmenu.SearchMenuConfig}
*
* @constructs SearchMenuUI
* @alias module:searchmenu.SearchMenuUI
* @see {@link module:searchmenu.SearchMenuAPI}
* @see {@link module:searchmenu.SearchMenuConfig}
*/
var instance = function (config) {
/**
* Configuration.
* @type {module:searchmenu.SearchMenuConfig}
* @protected
*/
this.config = config;
/**
* Search text that correspondents to the currently shown results.
* @type {String}
* @protected
*/
this.currentSearchText = "";
/**
* Timer that is used to wait before the menu is closed.
* @type {Timer}
* @protected
*/
this.focusOutTimer = null;
/**
* Timer that is used to wait before the search service is called.
* @type {Timer}
* @protected
*/
this.waitBeforeSearchTimer = null;
var search = document.getElementById(config.inputElementId);
onEscapeKey(search, function (event) {
getEventTarget(event).value = "";
hideMenu(config);
});
onArrowDownKey(search, handleEventWithConfig(config, focusFirstResult));
addEvent("keyup", search, function (event) {
if (this.waitBeforeSearchTimer !== null) {
clearTimeout(this.waitBeforeSearchTimer);
}
var newSearchText = getEventTarget(event).value;
this.waitBeforeSearchTimer = window.setTimeout(function () {
if (newSearchText !== this.currentSearchText || this.currentSearchText === "") {
updateSearch(newSearchText, config);
this.currentSearchText = newSearchText;
}
}, config.waitBeforeSearch);
});
var searchareaElement = document.getElementById(config.searchAreaElementId);
addEvent("focusin", searchareaElement, function () {
var searchInputElement = document.getElementById(config.inputElementId);
if (searchInputElement.value !== "") {
if (this.focusOutTimer != null) {
clearTimeout(this.focusOutTimer);
}
//TODO should only show results if there are some
//TODO could add a "spinner" when search is running
show(config.resultsView.viewElementId);
}
});
addEvent("focusout", searchareaElement, function () {
this.focusOutTimer = window.setTimeout(function () {
hideMenu(config);
}, config.waitBeforeClose);
});
};
function updateSearch(searchText, config) {
var matchList = document.getElementById(config.resultsView.listParentElementId);
matchList.innerHTML = "";
if (searchText.length === 0) {
hideMenu(config);
return;
}
show(config.resultsView.viewElementId);
getSearchResults(searchText, config);
}
function getSearchResults(searchText, config) {
//TODO should "retrigger" search when new filter options are selected (after each?)
var searchParameters = getSelectedOptions(config.filtersView.listParentElementId);
searchParameters[config.searchTextParameterName] = searchText;
config.addPredefinedParametersTo(searchParameters);
//TODO could provide optional build in search text highlighting
config.triggerSearch(searchParameters, function (jsonResult) {
displayResults(config.convertData(jsonResult), config);
});
//TODO should provide some info if search fails (service temporary unavailable, ...)
}
function displayResults(jsonResults, config) {
var index = 0;
for (index = 0; index < jsonResults.length; index += 1) {
addResult(jsonResults[index], index + 1, config);
}
}
function addResult(entry, i, config) {
var listElementId = config.resultsView.listEntryElementIdPrefix + "--" + i;
var resultElementText = createListEntryInnerHtmlText(entry, config.resultsView, listElementId, config.resolveTemplate);
var resultElement = createListEntryElement(entry, config.resultsView, listElementId, resultElementText);
addClass(resolveStyleClasses(entry, config.resultsView, config.resolveTemplate), resultElement);
forEachIdElementIncludingChildren(resultElement, config.onCreatedElement);
if (isMenuEntryWithFurtherDetails(entry)) {
onMenuEntrySelected(resultElement, handleEventWithEntriesAndConfig(entry.details, config, selectSearchResultToDisplayDetails));
onMouseOverDelayed(
resultElement,
config.waitBeforeMouseOver,
handleEventWithEntriesAndConfig(entry.details, config, selectSearchResultToDisplayDetails)
);
onMenuEntryChosen(resultElement, function () {
var selectedUrlTemplate = getSelectedUrlTemplate(config.filtersView.listParentElementId, getPropertyValueWithUndefinedDefault(entry, "category", ""));
if (selectedUrlTemplate) {
//TODO should add domain, baseurl, ... as data sources for variables to use inside the template
var targetURL = config.resolveTemplate(selectedUrlTemplate, entry);
config.navigateTo(targetURL);
}
});
}
if (isMenuEntryWithOptions(entry)) {
var options = entry.options;
//TODO should support details for filter options.
//TODO could skip sub menu, if there is only one option (with/without being default).
//TODO could be used for constants (pre selected single filter options) like "tenant-number", "current-account"
//TODO could remove the original search result filter when the default option is pre selected (and its options are copied).
if (isMenuEntryWithDefault(entry)) {
options = insertAtBeginningIfMissing(entry.options, entry["default"][0], equalProperties(["value"]));
var filterOptionsElement = createFilterOption(entry["default"][0], options, config.filtersView, config);
addDefaultFilterOptionModificationHandler(filterOptionsElement, options, config);
}
onMenuEntrySelected(resultElement, handleEventWithEntriesAndConfig(entry.options, config, selectSearchResultToDisplayFilterOptions));
onMenuEntryChosen(resultElement, handleEventWithEntriesAndConfig(entry.options, config, selectSearchResultToDisplayFilterOptions));
}
addMainMenuNavigationHandlers(resultElement, config);
}
function equalProperties(propertyNames) {
return function (existingObject, newObject) {
var index;
for (index = 0; index < propertyNames.length; index += 1) {
if (existingObject[propertyNames[index]] != newObject[propertyNames[index]]) {
return false;
}
}
return true;
};
}
/**
* Adds the given entry at be beginning of the given array of entries if it's missing.
* The equalFunction determines, if the new value is missing (returns false) or not (returns true).
* If the entry to add is null, the entries are returned directly.
*
* @param {Object[]} entries
* @param {Object} entryToAdd
* @param {boolean} equalMatcher takes the existing and the new entry as parameters and returns true if they are considered "equal".
* @returns {Object[]}
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function insertAtBeginningIfMissing(entries, entryToAdd, equalMatcher) {
if (!entryToAdd) {
return entries;
}
var index;
var alreadyContainsEntryToAdd = false;
for (index = 0; index < entries.length; index += 1) {
if (equalMatcher(entries[index], entryToAdd)) {
alreadyContainsEntryToAdd = true;
break;
}
}
if (alreadyContainsEntryToAdd) {
return entries;
}
var result = [];
result.push(entryToAdd);
for (index = 0; index < entries.length; index += 1) {
result.push(entries[index]);
}
return result;
}
function isMenuEntryWithFurtherDetails(entry) {
return typeof entry.details !== "undefined";
}
function isMenuEntryWithOptions(entry) {
return typeof entry.options !== "undefined";
}
function isMenuEntryWithDefault(entry) {
return typeof entry["default"] !== "undefined";
}
/**
* Reacts to input events (keys, ...) to navigate through main menu entries.
*
* @param {Element} element to add event handlers
* @param {SearchMenuConfig} config search configuration
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function addMainMenuNavigationHandlers(element, config) {
onArrowDownKey(element, handleEventWithConfig(config, focusNextSearchResult));
onArrowUpKey(element, handleEventWithConfig(config, focusPreviousSearchResult));
onEscapeKey(element, handleEventWithConfig(config, focusSearchInput));
onArrowLeftKey(element, handleEventWithConfig(config, closeAssociatedSubMenus));
}
/**
* Reacts to input events (keys, ...) to navigate through sub menu entries.
*
* @param {Element} element to add event handlers
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function addSubMenuNavigationHandlers(element) {
onArrowDownKey(element, focusNextMenuEntry);
onArrowUpKey(element, focusPreviousMenuEntry);
onArrowLeftKey(element, returnToMainMenu);
onEscapeKey(element, returnToMainMenu);
}
function onMenuEntrySelected(element, eventHandler) {
onSpaceKey(element, eventHandler);
onArrowRightKey(element, eventHandler);
}
function onMenuEntryChosen(element, eventHandler) {
addEvent("mousedown", element, eventHandler);
onEnterKey(element, eventHandler);
}
function onSubMenuEntrySelected(element, eventHandler) {
addEvent("mousedown", element, eventHandler);
onEnterKey(element, eventHandler);
onSpaceKey(element, eventHandler);
}
function onFilterMenuEntrySelected(element, eventHandler) {
addEvent("mousedown", element, eventHandler);
onEnterKey(element, eventHandler);
onArrowRightKey(element, eventHandler);
}
function onFilterMenuEntryRemoved(element, eventHandler) {
onDeleteKey(element, eventHandler);
onBackspaceKey(element, eventHandler);
//TODO should also be possible with mouse (without using keys)
}
/**
* @param {SearchMenuConfig} config search configuration
* @param {EventListener} eventHandler event handler
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function handleEventWithConfig(config, eventHandler) {
return function (event) {
eventHandler(event, config);
};
}
/**
* @param {Object[]} entries raw data of the entry
* @param {module:searchmenu.SearchMenuConfig} config search configuration
* @param {EventListener} eventHandler event handler
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function handleEventWithEntriesAndConfig(entries, config, eventHandler) {
return function (event) {
eventHandler(event, entries, config);
};
}
/**
* This callback will be called, if there is not next or previous menu entry to navigate to.
* The implementation can decide, what to do using the given id properties.
*
* @callback module:searchmenu.MenuEntryNotFoundHandler
* @param {module:searchmenu.ListElementIdProperties} properties of the element id
*/
/**
* This function returns the ID for the first sub menu entry using the given type name (= name of the sub menu).
*
* @callback module:searchmenu.SubMenuId
* @param {string} type name of the sub menu entries
*/
/**
* @typedef {Object} module:searchmenu.ListElementIdProperties
* @property {id} id Original ID
* @property {string} type Type of the list element
* @property {number} index Index of the list element
* @property {string} previousId ID of the previous list element
* @property {string} nextId ID of the next list element
* @property {string} firstId ID of the first list element
* @property {string} lastId ID of the last list element
* @property {module:searchmenu.SubMenuId} subMenuId Returns the ID of the first sub menu entry (with the given type name as parameter)
* @property {string} mainMenuId ID of the main menu entry e.g. to leave the sub menu. Equals to the id, if it already is a main menu entry
* @property {boolean} hiddenFieldsId ID of the embedded hidden field, that contains all public information of the described entry as JSON.
* @property {boolean} hiddenFields Parses the JSON inside the "hiddenFieldsId"-Element and returns the object with the described entry.
* @property {boolean} isFirstElement true, if it is the first element in the list
* @property {boolean} isSubMenu true, if it is the ID of an sub menu entry
*/
/**
* Extracts properties like type and index
* from the given list element id string.
*
* @param {string} id
* @return {module:searchmenu.ListElementIdProperties} list element id properties
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function extractListElementIdProperties(id) {
var separator = "--";
var splittedId = id.split(separator);
if (splittedId.length < 2) {
console.log("expected at least one '" + separator + "' separator inside the id " + id);
}
var extractedMainMenuType = splittedId[0];
var extractedMainMenuIndex = parseInt(splittedId[1]);
var extractedType = splittedId[splittedId.length - 2];
var extractedIndex = parseInt(splittedId[splittedId.length - 1]);
var idWithoutIndex = id.substring(0, id.lastIndexOf(extractedIndex) - separator.length);
return {
id: id,
type: extractedType,
index: extractedIndex,
previousId: idWithoutIndex + separator + (extractedIndex - 1),
nextId: idWithoutIndex + separator + (extractedIndex + 1),
firstId: idWithoutIndex + separator + "1",
lastId: idWithoutIndex + separator + document.getElementById(id).parentElement.childNodes.length,
mainMenuId: extractedMainMenuType + separator + extractedMainMenuIndex,
mainMenuIndex: extractedMainMenuIndex,
hiddenFieldsId: id + separator + "fields",
isFirstElement: extractedIndex <= 1,
isSubMenu: splittedId.length > 3,
subMenuId: function (typeName) {
return id + separator + typeName + separator + "1";
},
replaceMainMenuIndex: function (newIndex) {
var newMainMenuIndex = extractedMainMenuType + separator + newIndex;
return newMainMenuIndex + id.substring(this.mainMenuId.length);
},
getNewIndexAfterRemovedMainMenuIndex: function (removedIndex) {
if (extractedMainMenuIndex < removedIndex) {
return id;
}
if (extractedMainMenuIndex == removedIndex) {
throw new Error("index " + removedIndex + " should had been removed.");
}
return this.replaceMainMenuIndex(extractedMainMenuIndex - 1);
},
hiddenFields: function () {
var hiddenFieldsElement = document.getElementById(id + separator + "fields");
var hiddenFieldsJson = getPropertyValueWithUndefinedDefault(hiddenFieldsElement, "textContent", hiddenFieldsElement.innerText);
return JSON.parse(hiddenFieldsJson);
}
};
}
function focusSearchInput(event, config) {
var resultEntry = getEventTarget(event);
var inputElement = document.getElementById(config.inputElementId);
resultEntry.blur();
inputElement.focus();
selectionrange.moveCursorToEndOf(inputElement);
preventDefaultEventHandling(event); //skips cursor position change on key up once
hideSubMenus(config);
return inputElement;
}
function focusFirstResult(event, config) {
var selectedElement = getEventTarget(event);
var firstResult = document.getElementById(config.resultsView.listEntryElementIdPrefix + "--1");
if (firstResult) {
selectedElement.blur();
firstResult.focus();
}
}
function focusNextSearchResult(event, config) {
focusNextMenuEntry(event, function (menuEntryIdProperties) {
var next = null;
if (menuEntryIdProperties.type === config.resultsView.listEntryElementIdPrefix) {
//select first filter entry after last result/match entry
//TODO could find a better way (without config?) to navigate from last search result to first options/filter entry
next = document.getElementById(config.filterOptionsView.listEntryElementIdPrefix + "--1");
}
if (next === null) {
//select first result/match entry after last filter entry (or whenever nothing is found)
next = document.getElementById(config.resultsView.listEntryElementIdPrefix + "--1");
}
return next;
});
hideSubMenus(config);
}
function focusPreviousSearchResult(event, config) {
focusPreviousMenuEntry(event, function (menuEntryIdProperties) {
var previous = null;
if (menuEntryIdProperties.type === config.filterOptionsView.listEntryElementIdPrefix) {
//select last result entry when arrow up is pressed on first filter entry
//TODO could find a better way (without config?) to navigate from first options/filter entry to last search result?
var resultElementsCount = getListElementCountOfType(config.resultsView.listEntryElementIdPrefix);
previous = document.getElementById(config.resultsView.listEntryElementIdPrefix + "--" + resultElementsCount);
}
if (previous === null) {
//select input, if there is no previous entry.
return focusSearchInput(event, config);
}
return previous;
});
hideSubMenus(config);
}
/**
* Selects and focusses the next menu entry.
*
* @param {Event} event
* @param {module:searchmenu.MenuEntryNotFoundHandler} onMissingNext is called, if no "next" entry could be found.
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function focusNextMenuEntry(event, onMissingNext) {
var menuEntry = getEventTarget(event);
var menuEntryIdProperties = extractListElementIdProperties(menuEntry.id);
if (menuEntryIdProperties.isSubMenu) {
preventDefaultEventHandling(event); //skips e.g. scrolling whole screen down when focus is inside sub menu
}
var next = document.getElementById(menuEntryIdProperties.nextId);
if (next == null && typeof onMissingNext === "function") {
next = onMissingNext(menuEntryIdProperties);
}
if (next == null) {
next = document.getElementById(menuEntryIdProperties.firstId);
}
if (next != null) {
menuEntry.blur();
next.focus();
}
}
/**
* Selects and focusses the previous menu entry.
*
* @param {Event} event
* @param {module:searchmenu.MenuEntryNotFoundHandler} onMissingPrevious is called, if no "previous" entry could be found.
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function focusPreviousMenuEntry(event, onMissingPrevious) {
var menuEntry = getEventTarget(event);
var menuEntryIdProperties = extractListElementIdProperties(menuEntry.id);
if (menuEntryIdProperties.isSubMenu) {
preventDefaultEventHandling(event); //skips e.g. scrolling whole screen up when focus is inside sub menu
}
var previous = document.getElementById(menuEntryIdProperties.previousId);
if (previous == null && typeof onMissingPrevious === "function") {
previous = onMissingPrevious(menuEntryIdProperties);
}
if (previous == null) {
previous = document.getElementById(menuEntryIdProperties.lastId);
}
if (previous != null) {
menuEntry.blur();
previous.focus();
}
}
/**
* Gets called when a filter option is selected and copies it into the filter view, where all selected filters are collected.
* @param {Event} event
* @param {DescribedEntry} entries
* @param {module:searchmenu.SearchMenuConfig} config
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function selectFilterOption(event, entries, config) {
var selectedEntry = getEventTarget(event);
var selectedEntryData = findSelectedEntry(selectedEntry.id, entries, equalProperties(["fieldName", "value"]));
var filterOptionsElement = createFilterOption(selectedEntryData, entries, config.filtersView, config);
//TODO could detect default entry if necessary and call "addDefaultFilterOptionModificationHandler" instead
addFilterOptionModificationHandler(filterOptionsElement, entries, config);
preventDefaultEventHandling(event);
returnToMainMenu(event);
}
function createFilterOption(selectedEntryData, entries, view, config) {
var filterElements = getListElementCountOfType(view.listEntryElementIdPrefix);
var filterElementId = view.listEntryElementIdPrefix + "--" + (filterElements + 1);
var filterCategory = getPropertyValueWithUndefinedDefault(selectedEntryData, "category", "");
var filterElement = getListEntryByFieldName(filterCategory, selectedEntryData.fieldName, view.listParentElementId);
var isAlreadyExistingFilter = filterElement != null;
if (isAlreadyExistingFilter) {
var updatedText = createListEntryInnerHtmlText(selectedEntryData, view, filterElement.id, config.resolveTemplate);
filterElement = updateListEntryElement(filterElement, updatedText);
return filterElement;
}
var filterElementText = createListEntryInnerHtmlText(selectedEntryData, view, filterElementId, config.resolveTemplate);
filterElement = createListEntryElement(selectedEntryData, view, filterElementId, filterElementText);
addClass(resolveStyleClasses(selectedEntryData, view, config.resolveTemplate), filterElement);
forEachIdElementIncludingChildren(filterElement, config.onCreatedElement);
onFilterMenuEntrySelected(filterElement, handleEventWithEntriesAndConfig(entries, config, selectSearchResultToDisplayFilterOptions));
addMainMenuNavigationHandlers(filterElement, config);
return filterElement;
}
function addFilterOptionModificationHandler(filterElement, entries, config) {
onSpaceKey(filterElement, toggleFilterEntry);
onFilterMenuEntryRemoved(filterElement, handleEventWithConfig(config, removeFilterElement));
}
function addDefaultFilterOptionModificationHandler(filterElement, entries, config) {
onSpaceKey(filterElement, handleEventWithEntriesAndConfig(entries, config, selectSearchResultToDisplayFilterOptions));
//TODO could reset elements to their default value upon deletion.
}
/**
* Searches all child elements of the given parent element
* for an entry with the given fieldName contained in the hidden fields structure.
*
* @param {String} category of the element to search for
* @param {String} fieldName of the element to search for
* @param {String} listParentElementId id of the parent element that child nodes will be searched
* @returns {HTMLElement} returns the element that matches the given fieldName or null, if it hadn't been found.
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function getListEntryByFieldName(category, fieldName, listParentElementId) {
var globalCategoryResult = null;
var result = forEachListEntryElement(listParentElementId, function (element) {
var listElementHiddenFields = extractListElementIdProperties(element.id).hiddenFields();
if (listElementHiddenFields.fieldName === fieldName) {
var elementCategory = getPropertyValueWithUndefinedDefault(listElementHiddenFields, "category", "");
if (elementCategory === "") {
globalCategoryResult = element;
} else if (elementCategory === category) {
return element;
}
}
});
return (result != null)? result : globalCategoryResult;
}
/**
* Returns the property value of the object or - if undefined - the default value.
* @param {Object} object
* @param {String} propertyName
* @param {Object} defaultValue
* @returns the property value of the object or - if not set - the default value.
*/
function getPropertyValueWithUndefinedDefault(object, propertyName, defaultValue) {
if (typeof object[propertyName] === "undefined") {
return defaultValue;
}
return object[propertyName];
}
/**
* Gets the currently selected url template for navigation.
*
* @param {String} listParentElementId id of the parent element that child nodes will be searched
* @param {String} category the url template needs to belong to the same category
* @returns {String} returns the url template or null, if nothing could be found
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function getSelectedUrlTemplate(listParentElementId, category) {
return forEachListEntryElement(listParentElementId, function (element) {
var listElementHiddenFields = extractListElementIdProperties(element.id).hiddenFields();
var urlTemplate = getPropertyValueWithUndefinedDefault(listElementHiddenFields, "urltemplate", [""])[0];
if (urlTemplate === "") {
return null; // entry has no url template
}
var elementCategory = getPropertyValueWithUndefinedDefault(listElementHiddenFields, category, "");
if ((elementCategory != category) && (elementCategory !== "")) {
return null; // entry belongs to another category
}
if (hasClass("inactive", element)) {
return null; // entry is inactive
}
return urlTemplate.value;
});
}
function getSelectedOptions(listParentElementId) {
var result = {};
forEachListEntryElement(listParentElementId, function (element) {
var hiddenFields = extractListElementIdProperties(element.id).hiddenFields();
if (typeof hiddenFields.fieldName === "undefined" || typeof hiddenFields.value === "undefined") {
return null;
}
if (hasClass("inactive", element)) {
return null; // entry is inactive
}
result[hiddenFields.fieldName] = hiddenFields.value;
});
return result;
}
/**
* This function is called for every html element of a given parent.
*
* @callback module:searchmenu.ListElementFunction
* @param {Element} listElement name of the sub menu entries
* @return {Object} optional result to exit the loop or null otherwise.
*/
/**
* Iterates through all child nodes of the given parent and calls the given function.
* If the function returns a value, it will be returned directly.
* If the function returns nothing, the iteration continues.
* @param {String} listParentElementId
* @param {module:searchmenu.ListElementFunction} listEntryElementFunction
* @returns {Object} result of the first entry element function, that had returned one, or null.
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function forEachListEntryElement(listParentElementId, listEntryElementFunction) {
var listParentElement = document.getElementById(listParentElementId);
var i, listElement, result;
for (i = 0; i < listParentElement.childNodes.length; i += 1) {
listElement = listParentElement.childNodes[i];
result = listEntryElementFunction(listElement);
if (result) {
return result;
}
}
return null;
}
/**
* Extracts the entry data that it referred by the element given by its ID out of the list of data entries.
* @param {string} element id
* @param {DescribedEntry[]} array of described entries
* @param {boolean} equalMatcher takes the existing and the new entry as parameters and returns true if they are considered "equal".
* @returns {DescribedEntry} described entry out of the given entries, that suits the element given by its id.
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function findSelectedEntry(id, entries, equalsMatcher) {
var selectedEntryIdProperties = extractListElementIdProperties(id);
var selectedEntryHiddenFields = selectedEntryIdProperties.hiddenFields();
var entryIndex;
var currentlySelected;
for (entryIndex = 0; entryIndex < entries.length; entryIndex += 1) {
currentlySelected = entries[entryIndex];
if (equalsMatcher(currentlySelected, selectedEntryHiddenFields)) {
return currentlySelected;
}
}
console.log("error: no selected entry found for id " + id + " in " + entries);
return null;
}
function selectSearchResultToDisplayDetails(event, entries, config) {
hideSubMenus(config);
selectSearchResultToDisplaySubMenu(event, entries, config.detailView, config);
preventDefaultEventHandling(event);
}
function selectSearchResultToDisplayFilterOptions(event, entries, config) {
hideSubMenus(config);
selectSearchResultToDisplaySubMenu(event, entries, config.filterOptionsView, config);
}
function selectSearchResultToDisplaySubMenu(event, entries, subMenuView, config) {
clearAllEntriesOfElementWithId(subMenuView.listParentElementId);
var selectedElement = getEventTarget(event);
var subMenuEntry = null;
var subMenuElement = null;
var subMenuIndex = 0;
var subMenuEntryId = selectedElement.id + "--" + subMenuView.listEntryElementIdPrefix;
var subMenuFirstEntry = null;
var subMenuElementText;
for (subMenuIndex = 0; subMenuIndex < entries.length; subMenuIndex += 1) {
subMenuEntry = entries[subMenuIndex];
subMenuEntryId = selectedElement.id + "--" + subMenuView.listEntryElementIdPrefix + "--" + (subMenuIndex + 1);
subMenuElementText = createListEntryInnerHtmlText(subMenuEntry, subMenuView, subMenuEntryId, config.resolveTemplate);
subMenuElement = createListEntryElement(subMenuEntry, subMenuView, subMenuEntryId, subMenuElementText);
addClass(resolveStyleClasses(subMenuEntry, subMenuView, config.resolveTemplate), subMenuElement);
forEachIdElementIncludingChildren(subMenuElement, config.onCreatedElement);
if (subMenuView.isSelectableFilterOption) {
addSubMenuNavigationHandlers(subMenuElement);
onSubMenuEntrySelected(subMenuElement, handleEventWithEntriesAndConfig(entries, config, selectFilterOption));
}
if (subMenuIndex === 0) {
subMenuFirstEntry = subMenuElement;
}
}
var divParentOfSelectedElement = parentThatMatches(selectedElement, function (element) {
return element.tagName == "DIV";
});
var subMenuViewElement = document.getElementById(subMenuView.viewElementId);
var alignedSubMenuXPosition = divParentOfSelectedElement.offsetWidth + 15;
var alignedSubMenuYPosition = getYPositionOfElement(selectedElement) + getScrollY();
subMenuViewElement.style.left = alignedSubMenuXPosition + "px";
subMenuViewElement.style.top = alignedSubMenuYPosition + "px";
showElement(subMenuViewElement);
if (subMenuView.isSelectableFilterOption) {
selectedElement.blur();
subMenuFirstEntry.focus();
}
}
/**
* Exit sub menu from event entry and return to main menu.
* @param {InputEvent} event
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function returnToMainMenu(event) {
var subMenuEntryToExit = getEventTarget(event);
var subMenuEntryToExitProperties = extractListElementIdProperties(subMenuEntryToExit.id);
var mainMenuEntryToSelect = document.getElementById(subMenuEntryToExitProperties.mainMenuId);
subMenuEntryToExit.blur();
mainMenuEntryToSelect.focus();
hideViewOf(subMenuEntryToExit);
}
function closeAssociatedSubMenus(event, config) {
hideSubMenus(config);
}
/**
* Prevents the given event inside an event handler to get handled anywhere else.
* Pressing the arrow key up can lead to scrolling up the view. This is not useful,
* if the arrow key navigates the focus inside a sub menu, that is fully contained inside the current view.
* @param {InputEvent} inputevent
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function preventDefaultEventHandling(inputevent) {
if (typeof inputevent.preventDefault !== "undefined") {
inputevent.preventDefault();
} else {
inputevent.returnValue = false;
}
}
//TODO could be extracted as ponyfill
/**
* Browser compatible Y position of the given element.
* @returns {number} y position in pixel
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function getYPositionOfElement(element) {
var selectedElementPosition = element.getBoundingClientRect();
if (typeof selectedElementPosition.y !== "undefined") {
return selectedElementPosition.y;
}
return selectedElementPosition.top;
}
//TODO could be extracted as ponyfill
/**
* Browser compatible version of the standard "window.scrollY".
* @returns {number} y scroll position in pixel
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function getScrollY() {
var supportPageOffset = typeof window.pageYOffset !== "undefined";
if (supportPageOffset) {
return window.pageYOffset;
}
var isCSS1Compatible = (document.compatMode || "") === "CSS1Compat";
if (isCSS1Compatible) {
return document.documentElement.scrollTop;
}
return document.body.scrollTop;
}
function clearAllEntriesOfElementWithId(elementId) {
var node = document.getElementById(elementId);
// Fastest way to delete child nodes in Chrome and FireFox according to
// https://stackoverflow.com/questions/3955229/remove-all-child-elements-of-a-dom-node-in-javascript
if (typeof node.cloneNode === "function" && typeof node.replaceChild === "function") {
var cNode = node.cloneNode(false);
node.parentNode.replaceChild(cNode, node);
} else {
node.innerHTML = "";
}
}
/**
* Toggles a filter to inactive and vice versa.
* @param {InputEvent} event
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function toggleFilterEntry(event) {
preventDefaultEventHandling(event);
var filterElement = getEventTarget(event);
toggleClass("inactive", filterElement);
}
function removeFilterElement(event, config) {
preventDefaultEventHandling(event);
focusPreviousSearchResult(event, config);
removeChildElement(event);
}
/**
* Removes the event target element from its parent.
* @param {InputEvent} event
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function removeChildElement(event) {
var element = getEventTarget(event);
var parentElement = element.parentElement;
var indexOfRemovedElement = extractListElementIdProperties(element.id).mainMenuIndex;
parentElement.removeChild(element);
forEachChildRecursively(parentElement, 0, 5, function (entry) {
if (entry.id) {
entry.id = extractListElementIdProperties(entry.id).getNewIndexAfterRemovedMainMenuIndex(indexOfRemovedElement);
}
});
}
function forEachChildRecursively(element, depth, maxDepth, callback) {
if (depth > maxDepth || !element.childNodes) {
return;
}
forEachEntryIn(element.childNodes, function (entry) {
callback(entry);
forEachChildRecursively(entry, depth + 1, maxDepth, callback);
});
}
/**
* This function will be called for every found element
* @callback module:searchmenu.ElementFoundListener
* @param {Element} foundElement
* @param {boolean} isParent true, if it is the created parent. false, if it is a child within the created parent.
*/
/**
* The given callback will be called for the given parent and all its direct child nodes, that contain an id property.
* @param {Element} element parent to be inspected
* @param {module:searchmenu.ElementFoundListener} callback will be called for every found child and the given parent itself
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function forEachIdElementIncludingChildren(element, callback) {
if (element.id) {
callback(element, true);
}
forEachEntryIn(element.childNodes, function (element) {
if (element.id) {
callback(element, false);
}
});
}
function forEachEntryIn(array, callback) {
var index = 0;
for (index = 0; index < array.length; index += 1) {
callback(array[index], index + 1); //index parameter starts with 1 (1 instead of 0 based)
}
}
/**
* @param {String} list element type name e.g. "li".
* @return {number} list element count of the given type
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function getListElementCountOfType(listelementtype) {
var firstListEntry = document.getElementById(listelementtype + "--1");
if (firstListEntry === null) {
return 0;
}
return firstListEntry.parentElement.childNodes.length;
}
/**
* Updates an already existing list entry element to be used for search results, filter options, details and filters.
*
* @param {Node} already existing element
* @param {String} text updated element text
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function updateListEntryElement(existingElement, text) {
existingElement.innerHTML = text;
return existingElement;
}
/**
* Creates a new list entry element to be used for search results, filter options, details and filters.
*
* @param {DescribedEntry} entry entry data
* @param {module:searchmenu.SearchViewDescription} view description
* @param {number} id id of the list element
* @param {String} text text of the list element
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function createListEntryElement(entry, view, id, text) {
var listElement = createListElement(text, id, view.listEntryElementTag);
var parentElement = document.getElementById(view.listParentElementId);
parentElement.appendChild(listElement);
return listElement;
}
/**
* Creates the inner HTML Text for a list entry to be used for search results, filter options, details and filters.
*
* @param {DescribedEntry} entry entry data
* @param {module:searchmenu.SearchViewDescription} view description
* @param {number} id id of the list element
* @param {module:searchmenu.TemplateResolver} resolveTemplate function that resolves variables inside a template with contents of a source object
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function createListEntryInnerHtmlText(entry, view, id, resolveTemplate) {
//TODO could support template inside html e.g. referenced by id (with convention over code)
//TODO should limit length of resolved variables
var text = resolveTemplate(view.listEntryTextTemplate, entry);
if (typeof entry.summaries !== "undefined") {
text = resolveTemplate(view.listEntrySummaryTemplate, entry);
}
var json = JSON.stringify(entry); //needs to be without spaces
text += '<p id="' + id + '--fields" style="display: none">' + json + "</p>";
return text;
}
function resolveStyleClasses(entry, view, resolveTemplate) {
var resolvedClasses = resolveTemplate(view.listEntryStyleClassTemplate, entry);
resolvedClasses = resolveTemplate(resolvedClasses, { view: view });
return resolvedClasses;
}
/**
* Creates a new list element to be used for search results.
*
* @param {string} text inside the list element
* @param {number} id id of the list element
* @param {string} elementTag tag (e.g. "li") for the element
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function createListElement(text, id, elementTag) {
var element = document.createElement(elementTag);
element.id = id;
element.tabIndex = "0";
element.innerHTML = text;
return element;
}
function hideMenu(config) {
hide(config.resultsView.viewElementId);
hide(config.detailView.viewElementId);
hide(config.filterOptionsView.viewElementId);
}
function hideSubMenus(config) {
hide(config.detailView.viewElementId);
hide(config.filterOptionsView.viewElementId);
}
/**
* Shows the element given by its id.
* @param {Element} elementId ID of the element that should be shown
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function show(elementId) {
showElement(document.getElementById(elementId));
}
/**
* Shows the given element.
* @param {Element} element element that should be shown
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function showElement(element) {
addClass("show", element);
}
/**
* Hides the element given by its id.
* @param elementId ID of the element that should be hidden
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function hide(elementId) {
hideElement(document.getElementById(elementId));
}
/**
* Hides the view (by removing the class "show"), that contains the given element.
* The view is identified by the existing style class "show".
* @param {Element} element
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function hideViewOf(element) {
var parentWithShowClass = parentThatMatches(element, function (parent) {
return hasClass("show", parent);
});
if (parentWithShowClass != null) {
hideElement(parentWithShowClass);
return;
}
}
/**
* @callback module:searchmenu.ElementPredicate
* @param {Element} element
* @returns {boolean} true, when the predicate matches the given element, false otherwise.
*/
/**
* Returns the parent of the element (or the element itself), that matches the given predicate.
* Returns null, if no element had been found.
*
* @param {Element} element
* @param {module:searchmenu.ElementPredicate} predicate
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function parentThatMatches(element, predicate) {
var parentNode = element;
while (parentNode != null) {
if (predicate(parentNode)) {
return parentNode;
}
parentNode = parentNode.parentNode;
}
return null;
}
/**
* Hides the given element.
* @param element element that should be hidden
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function hideElement(element) {
removeClass("show", element);
}
function toggleClass(classToToggle, element) {
if (hasClass(classToToggle, element)) {
removeClass(classToToggle, element);
} else {
addClass(classToToggle, element);
}
}
function addClass(classToAdd, element) {
removeClass(classToAdd, element);
var separator = element.className.length > 0 ? " " : "";
element.className += separator + classToAdd;
}
function removeClass(classToRemove, element) {
var regex = new RegExp("\\s?\\b" + classToRemove + "\\b", "gi");
element.className = element.className.replace(regex, "");
}
function hasClass(classToLookFor, element) {
return element.className != null && element.className.indexOf(classToLookFor) >= 0;
}
function onMouseOverDelayed(element, delayTime, eventHandler) {
addEvent("mouseover", element, function (event) {
this.originalEvent = cloneObject(event);
this.delayedHandlerTimer = window.setTimeout(function () {
eventHandler(typeof this.originalEvent !== "undefined" ? this.originalEvent : event);
}, delayTime);
this.preventEventHandling = function () {
if (this.delayedHandlerTimer !== null) {
clearTimeout(this.delayedHandlerTimer);
}
};
addEvent("mouseout", element, this.preventEventHandling);
addEvent("mousedown", element, this.preventEventHandling);
addEvent("keydown", element, this.preventEventHandling);
});
}
function cloneObject(source) {
var result = {};
var propertyNames = Object.keys(source);
for (var propertyIndex = 0; propertyIndex < propertyNames.length; propertyIndex++) {
var propertyName = propertyNames[propertyIndex];
var propertyValue = source[propertyName];
result[propertyName] = propertyValue;
}
return result;
}
function onEscapeKey(element, eventHandler) {
addEvent("keydown", element, function (event) {
if (event.key == "Escape" || event.key == "Esc" || keyCodeOf(event) == 27) {
eventHandler(event);
}
});
}
function onEnterKey(element, eventHandler) {
addEvent("keydown", element, function (event) {
if (event.key == "Enter" || keyCodeOf(event) == 13) {
eventHandler(event);
}
});
}
function onSpaceKey(element, eventHandler) {
addEvent("keydown", element, function (event) {
if (event.key == " " || event.key == "Spacebar" || keyCodeOf(event) == 32) {
eventHandler(event);
}
});
}
function onDeleteKey(element, eventHandler) {
addEvent("keydown", element, function (event) {
if (event.key == "Del" || event.key == "Delete" || keyCodeOf(event) == 46) {
eventHandler(event);
}
});
}
function onBackspaceKey(element, eventHandler) {
addEvent("keydown", element, function (event) {
if (event.key == "Backspace" || keyCodeOf(event) == 8) {
eventHandler(event);
}
});
}
function onArrowUpKey(element, eventHandler) {
addEvent("keydown", element, function (event) {
if (event.key == "ArrowUp" || event.key == "Up" || keyCodeOf(event) == 38) {
eventHandler(event);
}
});
}
function onArrowDownKey(element, eventHandler) {
addEvent("keydown", element, function (event) {
if (event.key == "ArrowDown" || event.key == "Down" || keyCodeOf(event) == 40) {
eventHandler(event);
}
});
}
function onArrowRightKey(element, eventHandler) {
addEvent("keydown", element, function (event) {
if (event.key == "ArrowRight" || event.key == "Right" || keyCodeOf(event) == 39) {
eventHandler(event);
}
});
}
function onArrowLeftKey(element, eventHandler) {
addEvent("keydown", element, function (event) {
if (event.key == "ArrowLeft" || event.key == "Left" || keyCodeOf(event) == 37) {
eventHandler(event);
}
});
}
function addEvent(eventName, element, eventHandler) {
eventlistener.addEventListener(eventName, element, eventHandler);
}
/**
* @returns {Element} target of the event
*/
function getEventTarget(event) {
return eventtarget.getEventTarget(event);
}
/**
* Returns the key code of the event or -1 if it is no available.
* @param {KeyboardEvent} event
* @return key code or -1 if not available
* @protected
* @memberof module:searchmenu.SearchMenuUI
*/
function keyCodeOf(event) {
return typeof event.keyCode === "undefined" ? -1 : event.keyCode;
}
return instance;
})();