views_faceted_filters_js-0.0.18/libraries/client-side-faceted-filters/dist/js/csff.js
libraries/client-side-faceted-filters/dist/js/csff.js
/**
* Client-side faceted filters.
*
* @param {Element} targetElement
* The target Element.
* @param {array} settings
* The settings array.
* @return {Csff}
*/
function Csff(targetElement, settings) {
/**
* @var {object}
*/
const self = this;
/**
* Defines the default settings and can be used as template for providing
* custom settings.
*
* All unprovided custom settings will fall back to their defaults.
*
* @var {object}
*/
const defaultSettings = {
// General settings:
injection_selector: "#facets", // Facets container
injection_method: "beforeend", // Facets container injection method
// Facet filters:
facets_show: true, // Enable faceted filters
facets: [
// EXAMPLE:
// {
// id: 'color',
// title: 'Color',
// description: 'Filter by color:',
// count_show: true,
// values_sort: 'value',
// values_sort_direction: 'asc',
// multivalue_separator: '|',
// weight: 0,
// },
// {
// id: 'size',
// title: 'Size',
// description: 'Filter by size:',
// count_show: true,
// values_sort: 'count',
// values_sort_direction: 'desc',
// multivalue_separator: '|',
// weight: 1,
// }
],
// Fulltext search:
fulltext_search_show: false, // Enable full text search
fulltext_search_title: "Search",
fulltext_search_description: "",
fulltext_search_placeholder: "",
fulltext_search_delay: 500,
// Reset button:
reset_button_show: true, // Show reset button
reset_button_title: "Reset",
reset_button_description: "",
reset_button_label: "Reset", // Reset button label
// Empty text:
results_empty_text: "No matching results.",
// HTML templates:
template_facets_container: '<form class="csff-facets" action="#"><!-- Prevent implicit submission of the form -->\
<button type="submit" disabled style="display: none" aria-hidden="true"></button></form>',
template_facet: '<section class="csff-facet view-faceted-filters-js-facet block block--js" id="csff-facet-${id}">\
<h2 class="csff-facet__title block__title">${title}</h2>\
<div class="csff-facet__content block__content">\
<div class="csff-facet__description block__description">${description}</div>\
<div class="csff-facet__values">\
${content}\
</div>\
</div>\
</section>',
template_facet_value: '<div class="csff-facet__value form-item form-type-checkbox"><input type="checkbox" name="${facetId}" id="${id}" value="${value}" class="csff-facet__value-input form-checkbox" /><label for="${id}" class="option csff-facet__value-label"><span class="csff-facet__value-label-text">${value}</span><span class="csff-facet__value-count">${count}</span></label></div>',
template_facet_reset: '<div class="csff-facets__reset">\
<button class="button button--reset">${label}</button>\
</div>',
template_search: '<div class="csff-facet__value form-item form-type-search"><input type="search" name="csff-search" class="csff-facet__value-input form-search" placeholder="${placeholder}" /></div>',
template_results_empty: '<div class="csff-results-empty hidden--results-empty"><div class="csff-results-empty__content">${content}</div></div>',
// The following options are typically not changed, but we even allow that
// for resolving possible conflicts:
_csff_controls_facets_container_selector: ".csff-facets",
_csff_item_selector: ".csff-item",
_csff_subitem_selector: ".csff-subitem",
_csff_filter_input_selector: ".csff-facet__value-input.form-checkbox",
_csff_search_input_selector: ".csff-facet__value-input.form-search",
_csff_reset_input_selector: ".csff-facets__reset .button--reset",
_csff_results_empty_selector: ".csff-results-empty",
_csff_facet_class_prefix: "csff-facet-",
_csff_facet_hidden_item_class: "hidden--filter",
_csff_facet_data_attribute_prefix: "csff-facet-",
_csff_facet_data_attribute_value_suffix: "--value",
_csff_search_class: "csff-search-input",
_csff_search_data_attribute_prefix: "csff-search--value",
_csff_search_hidden_item_class: "hidden--search",
_csff_results_empty_hidden_class: "hidden--results-empty",
};
settings = {...defaultSettings, ...settings };
/**
* @var array
*/
const facetIndex = {
// EXAMPLE:
// color: { // The facet id
// values: { // The items with values for the facet
// 'Red': { // Indexed by value for quick retrieval.
// value: 'Red',
// items: [] // If this is filled, this is a regular item facet.
// subitems: [] // If this is filled, this is a subitem facet.
// }
// }
// }
};
/**
* Initialize Csff. Called once on construct, you typically
* don't want to call this.
*
* @return {void}
*/
this.init = function() {
// Initialize controls:
self._initControls();
};
/**
* Execute filtering based on the currently selected facets & search inputs.
*
* @param {Element} triggeringElement
* @return {void}
*/
this.filter = function(triggeringElement) {
// Filter by search value
const searchInputElement = self._domGetSearchInputElement()
if (searchInputElement) {
// The search input element is present:
self._filterBySearch(searchInputElement.value);
}
const activeFacetFilters = {};
self._domGetFacetFilterInputElements().forEach((item, index) => {
if (item.checked) {
activeFacetFilters[item.name] = activeFacetFilters[item.name] || [];
activeFacetFilters[item.name].push(item.value);
}
});
self._domGetItemElements().forEach((item, index) => {
if (Object.entries(activeFacetFilters).length > 0) {
// There are active filters, initially hide all:
item.classList.add(settings._csff_facet_hidden_item_class);
} else {
// There are no active filters, unhide all:
item.classList.remove(settings._csff_facet_hidden_item_class);
}
});
// Apply the filter logic:
// We intentionally initialize these arrays
// with null to be able to determine if the result is empty or was never
// used. This is important for combining results.
let filterResultItems = null;
let filterResultSubitems = null;
for (const [facetId, values] of Object.entries(activeFacetFilters)) {
const facetFilterResultsItemsSet = new Set();
const facetFilterResultsSubitemsSet = new Set();
// Get the results from the single facet. Values of a single facet
// are always combined by OR (UNION)
const facetFilterResults = self._facetIndexFilterFacet(facetId, values);
if (Object.entries(facetFilterResults).length > 0) {
facetFilterResults.forEach((facetFilterResult) => {
if (!facetFilterResult.items.length > 0 && !facetFilterResult.subitems.length > 0) {
// This facet has no items at all. Nothing to do!
} else if (facetFilterResult.items.length > 0 && facetFilterResult.subitems.length > 0) {
// This facet has items and subitems, we don't support that combination yet!
// TODO: Implement this whenever it's needed :)
console.warn('Facet with id: "' + facetId + '" has items and subitems. This is not supported yet.')
} else if (facetFilterResult.items.length > 0) {
// This is a regular item facet!
// Add regular result items:
facetFilterResult.items.forEach((facetFilterResultItem) =>
facetFilterResultsItemsSet.add(facetFilterResultItem)
);
} else if (facetFilterResult.subitems.length > 0) {
// This is a subitem facet!
// Handle subitem result items (THIS IS SPECIAL!):
// Subitems have to be combined by AND (INTERSECT) on the same subitem.
// This is required to only return items with an existing COMBINATION
// of the selected properties on a subitem.
facetFilterResult.subitems.forEach((facetFilterResultSubitem) => {
facetFilterResultsSubitemsSet.add(self._getRepresentativeSubitem(facetFilterResultSubitem))
})
} else {
// Should never happen.
console.warn('That was unexpected... ;)')
}
})
// Now (if present) combine them with other facets.
// This is always done by AND (INTERSECT):
if (facetFilterResultsItemsSet.size > 0) {
if (filterResultItems === null || filterResultItems.length === 0) {
// No other results yet. We can't intersect with empty.
filterResultItems = [...facetFilterResultsItemsSet];
} else {
// Intersect with other facet results
filterResultItems = filterResultItems.filter((filterResult) => [...facetFilterResultsItemsSet].includes(filterResult));
}
}
// Now (if present) combine them with other subitem facets.
// This is always done by AND (INTERSECT):
if (facetFilterResultsSubitemsSet.size > 0) {
if (filterResultSubitems === null || filterResultSubitems.length === 0) {
// No other results yet. We can't intersect with empty.
filterResultSubitems = [...facetFilterResultsSubitemsSet];
} else {
// Intersect with other facet results
filterResultSubitems = filterResultSubitems.filter((filterResult) => [...facetFilterResultsSubitemsSet].includes(filterResult));
}
}
}
}
// Determine if subitems have been set at all.
// If filterResultSubitems == [] this means, that
// subitems were filtered, but no matching results found
// That means, that in contrast to filterResultSubitems === null
// the complete result must be empty.
// If filterResultSubitems contains items, their representative items
// have to be combined by AND (UNION):
if (filterResultSubitems !== null) {
let subitemRepresentatives = [];
if (filterResultSubitems.length > 0) {
subitemRepresentatives = self._getRepresentativeItems(filterResultSubitems)
}
// Now combine them with other facets.
// This is always done by AND (INTERSECT):
if (filterResultItems === null) {
// No other results yet. We can't intersect with empty.
filterResultItems = [...subitemRepresentatives];
} else {
// Intersect with other facet results
filterResultItems = filterResultItems.filter((filterResult) => [...subitemRepresentatives].includes(filterResult));
}
}
if (filterResultItems !== null && filterResultItems.length > 0) {
// Show the ones from the resultset:
filterResultItems.forEach((item) =>
item.classList.remove(settings._csff_facet_hidden_item_class)
)
}
// Show results empty text if there are no visible results:
let hasVisibleItems = false;
self._domGetItemElements().forEach((item, index) => {
if (item.offsetWidth > 0 || item.offsetHeight > 0) {
hasVisibleItems = true;
}
});
if (hasVisibleItems) {
self._domGetEmptyResultsElement().classList.add(settings._csff_results_empty_hidden_class)
} else {
self
._domGetEmptyResultsElement()
.classList.remove(settings._csff_results_empty_hidden_class)
}
};
/**
* Reset all filters and fulltext search. Show all results unfiltered.
*
* @return {void}
*/
this.reset = function() {
// Reset search input:
const searchInputElement = self._domGetSearchInputElement()
if (searchInputElement) {
// The search input element is present:
self._domGetSearchInputElement().value = "";
}
// Reset facet filters:
self._domGetFacetFilterInputElements().forEach((input, index) => {
input.checked = false;
});
self.filter(null);
};
/**
* Applies the full text filter on all items.
*
* @param {string} filterValue
* @return {void}
*/
this._filterBySearch = function(filterValue) {
filterValue = filterValue.trim();
if (filterValue.length == 0) {
self._domGetItemElements().forEach((item, index) => {
item.classList.remove(settings._csff_search_hidden_item_class);
});
return;
}
// Split words by space (multi word or search):
const words = filterValue.split(" ");
self._domGetItemElements().forEach((item, index) => {
item.classList.remove(settings._csff_search_hidden_item_class);
words.forEach((word) => {
if (!item.textContent.toLowerCase().includes(word.toLowerCase())) {
item.classList.add(settings._csff_search_hidden_item_class);
}
});
});
};
/**
* Helper function to get the representative (parent) DOM Elements
* for the given elements.
*
* This is required to find the elements to hide based on the given
* (e.g. child) element which contains the facet properties.
* If this is not (child of) an item, returns null.
*
* @see this._getRepresentativeItem
*
* @param {array} elements
* The DOM Elements to retrieve the representative {Element} items for.
* @return {array}
* The representative {Element} items.
*/
this._getRepresentativeItems = function(elements) {
let representativeItemsSet = new Set();
elements.forEach((element) => {
representativeItemsSet.add(self._getRepresentativeItem(element))
})
return [...representativeItemsSet];
}
/**
* Helper function to get the representative (parent) DOM Element
* for the given element.
*
* This is required to find the element to hide based on the given
* (e.g. child) element which contains the facet properties.
* If this is not (child of) an item, returns null.
*
* @see this._getRepresentativeItems
*
* @param {Element} element
* The DOM Element to retrieve the representative {Element} item for.
* @return {Element|null}
* The representative {Element} items.
*/
this._getRepresentativeItem = function(element) {
return element.closest(settings._csff_item_selector);
};
/**
* Returns the representative subitem if the given subitem (child) element
* If element is not (child of) a subitem, returns null.
*
*
* @param {Element} element
* @return {Element|null}
*/
this._getRepresentativeSubitem = function(element) {
return element.closest(settings._csff_subitem_selector);
};
/**
* Internal helper function to Initialize all faceted filter control UI
* elements in the DOM.
*
* @return {void}
*/
this._initControls = function _initControls() {
// Insert wrapping container:
const injectionTarget = self._domGetInjectionTargetElement();
if (!injectionTarget) {
throw new Error("Injection target could not be found.");
}
injectionTarget.insertAdjacentHTML(
settings.injection_method,
settings.template_facets_container
);
// Set the elemFacetsContainer variable to store the container:
const elemFacetsContainer = self._domGetFacetsContainerElement();
if (!elemFacetsContainer) {
throw new Error("Facets container could not be found.");
}
let hasActveFacets = false;
if (settings.facets_show) {
hasActveFacets = self._initControlsFacets();
}
if (settings.fulltext_search_show) {
self._initControlsSearch();
}
const showReset = settings.reset_button_show && (settings.fulltext_search_show || hasActveFacets)
if (showReset) {
self._initControlsReset();
}
// Insert hidden empty results container:
if (settings.results_empty_text) {
targetElement.insertAdjacentHTML(
"beforeend",
self._theme_results_empty(settings.results_empty_text)
);
}
};
/**
* Internal helper function to Initialize the facet controls UI.
*
* @return boolean
* Returns true if facets have been initialized, else false if there are none.
*/
this._initControlsFacets = function() {
let hasActveFacets = false;
// Collect the properties to build the facets:
self._collectProperties();
const { facets } = settings;
let facetsHtml = "";
// Sort facets by weight:
facets.sort((a, b) => a.weight - b.weight);
settings.facets.forEach((facet) => {
if (facet.id) {
const facetId = facet.id;
// Check if there are values for this facet id:
if (facetIndex.hasOwnProperty(facetId)) {
let facetValuesHTML = '';
// Sort values:
let facetValueSorted = Object.values(facetIndex[facetId]['values'])
switch (facet.values_sort) {
case 'value':
// Sort by value (natural)
facetValueSorted = facetValueSorted.sort((a, b) => {
return a.value.localeCompare(b.value, undefined, {
numeric: true,
sensitivity: 'base'
})
})
break;
case 'count':
// Sort by count
facetValueSorted = facetValueSorted.sort((a, b) => a.items.length - b.items.length);
break;
default:
// No sorting.
break;
}
if (facet.values_sort_direction == 'desc') {
facetValueSorted.reverse()
}
// Loop values and add them as checkboxes:
facetValueSorted.forEach((properties) => {
const value = properties.value;
const itemCount = (properties.subitems.length > 0 ? this._getRepresentativeItems(properties.subitems).length : properties.items.length)
const facetValueElementHTML = self._theme_facet_value(
facetId,
value,
itemCount
)
facetValuesHTML += facetValueElementHTML;
})
// Values for this facet are present:
// Create a container for this facet:
facetsHtml += self._theme_facet(
facetId,
facet.title,
facet.description,
facetValuesHTML
);
}
}
});
if (facetsHtml.length > 0) {
self
._domGetFacetsContainerElement()
.insertAdjacentHTML("beforeend", facetsHtml)
hasActveFacets = true;
}
// As all HTML was dynamically added, we have to bind the change event afterwards:
self._domGetFacetFilterInputElements().forEach((input) => {
input.addEventListener("change", function(e) {
self.filter(e.target);
});
});
return hasActveFacets;
};
/**
* Helper function to get all facet values for the given facetId.
*
* @param {string} facetId
* @return {Object}
*/
this._facetIndexGetFacetValues = function(facetId) {
if (facetIndex.hasOwnProperty(facetId)) {
return facetIndex[facetId].values;
}
return {};
};
/**
* Helper function to return all facet results for the given filterValues.
* Multiple filterValues are combined by OR (UNION) for the same facet.
* So all results are returned matching at least one filterValues item.
*
* @param {string} facetId
* @param {array} filterValues
* @return {array}
*/
this._facetIndexFilterFacet = function(facetId, filterValues = []) {
const values = self._facetIndexGetFacetValues(facetId);
const results = [];
filterValues.forEach((filterValue, index) => {
if (values.hasOwnProperty(filterValue)) {
results.push(values[filterValue]);
}
});
return results;
};
/**
* Helper function to collect all facet related properties (filterable values)
* from the items.
*
* Adds all collected properties on this.facetIndex.
*
* @return {void}
*/
this._collectProperties = function() {
const items = self._domGetItemElements();
items.forEach((item) => {
settings.facets.forEach((facet) => {
if (facet.id) {
const facetId = facet.id;
// Find defintion of facets data attribute:
const dataAttributeValueName = `data-${settings._csff_facet_data_attribute_prefix}${facetId}${settings._csff_facet_data_attribute_value_suffix}`;
let elem = false;
if (item.hasAttribute(dataAttributeValueName)) {
// The element defines the data attribute itself:
elem = item;
} else {
// Get child element with data attribute:
elem = item.querySelector(`[${dataAttributeValueName}]`);
}
if (elem) {
// Check if this is (or is part of) a subitem.
let subitem = elem.closest(settings._csff_subitem_selector);
if (subitem) {
// If this is (in) a subitem, we can expect more than one
// data attribute within the item. In this case, add them all
subitemElems = item.querySelectorAll(`[${dataAttributeValueName}]`);
subitemElems.forEach((subitemElem) => {
const value = subitemElem.getAttribute(dataAttributeValueName);
// Split value by separator as separate values:
const values = value.split(facet.multivalue_separator)
values.forEach((splitValue) => self._addToIndex(facetId, splitValue, false, subitemElem))
})
} else {
// This is a regular item, no subitem:
const value = elem.getAttribute(dataAttributeValueName);
// Split value by separator as separate values:
const values = value.split(facet.multivalue_separator)
values.forEach((splitValue) => self._addToIndex(facetId, splitValue, elem, false))
}
}
} else {
console.warn(
`Missing "id" for facet definition: ${JSON.stringify(facet)}`
);
}
});
});
};
/**
* Helper function to add facet properties to the index on this.facetIndex.
*
* @param {string} facetId
* @param {string} value
* @param {Element|boolean} element
* @param {Element|boolean} subitemElem
*
* @return {void}
*/
this._addToIndex = function(facetId, value, element = false, subitemElem = false) {
// Trim value:
value = value.trim();
if (value.length === 0) {
return;
}
if (!facetIndex.hasOwnProperty(facetId)) {
// No property for the facetId exists yet:
facetIndex[facetId] = {
values: {},
};
}
if (!facetIndex[facetId].values.hasOwnProperty(value)) {
// No such value exists yet for the given facetId:
facetIndex[facetId]['values'][value] = {
'value': value,
'items': [], // If this is filled, this is a regular item facet
'subitems': [], // If this is filled, this is a subitem facet
}
}
if (subitemElem) {
// If this is a subitem, push the subitem element.
// We determine the
facetIndex[facetId]['values'][value]['subitems'].push(subitemElem);
} else {
// Otherwise push the item element:
// Add the item element to the index:
facetIndex[facetId]['values'][value]['items'].push(element);
}
}
/**
* Internal helper function to Initialize the full text search controls UI.
*
* @return {void}
*/
this._initControlsSearch = function() {
self
._domGetFacetsContainerElement()
.insertAdjacentHTML(
"afterbegin",
self._theme_search(
settings.fulltext_search_title,
settings.fulltext_search_description,
settings.fulltext_search_placeholder
)
);
let typeout;
self._domGetSearchInputElement().addEventListener("input", (e) => {
if (typeout) {
clearTimeout(typeout);
}
typeout = setTimeout(() => {
self.filter(e.target);
}, settings.fulltext_search_delay);
});
};
/**
* Internal helper function to Initialize the reset controls UI.
*
* @return {void}
*/
this._initControlsReset = function() {
self
._domGetFacetsContainerElement()
.insertAdjacentHTML(
"beforeend",
self._theme_facet_reset(
settings.reset_button_title,
settings.reset_button_description,
settings.reset_button_label
)
);
self
._domGetResetButtonInputElement()
.addEventListener("click", function(e) {
self.reset();
// Prevent page reload:
e.preventDefault();
});
};
/**
* DOM query helper to retrieve the facets container element from Document.
*
* @return {Element}
*/
this._domGetFacetsContainerElement = function() {
return document.querySelector(
settings._csff_controls_facets_container_selector
);
};
/**
* DOM query helper to retrieve the injection target element from Document.
*
* @return {Element}
*/
this._domGetInjectionTargetElement = function() {
return document.querySelector(settings.injection_selector);
};
/**
* DOM query helper to retrieve all result item elements from the target
* element.
*
* @return {Element}
*/
this._domGetItemElements = function() {
return targetElement.querySelectorAll(settings._csff_item_selector);
};
/**
* DOM query helper to retrieve the injection target element from the
* facets container element.
*
* @return {Element}
*/
this._domGetSearchInputElement = function() {
return this._domGetFacetsContainerElement().querySelector(
settings._csff_search_input_selector
);
};
/**
* DOM query helper to retrieve the injection target element from the
* facets container element.
*
* @return {Element}
*/
this._domGetFacetFilterInputElements = function() {
return this._domGetFacetsContainerElement().querySelectorAll(
settings._csff_filter_input_selector
);
};
/**
* DOM query helper to retrieve the reset button element from the
* facets container element.
*
* @return {Element}
*/
this._domGetResetButtonInputElement = function() {
return this._domGetFacetsContainerElement().querySelector(settings._csff_reset_input_selector);
};
/**
* DOM query helper to retrieve the empty results container element from the
* facets container element.
*
* @return {Element}
*/
this._domGetEmptyResultsElement = function() {
return targetElement.querySelector(settings._csff_results_empty_selector);
};
/**
* Returns the "Facet container" template string, filled with given variables.
*
* @return {string}
*/
this._theme_facets_container = function() {
return self._interpolateTemplate(settings.template_facets_container, {});
};
/**
* Returns the "Facet" template string, filled with given variables.
*
* @param {string} id
* @param {string} title
* @param {string} description
* @param {string} content
* @return {string}
*/
this._theme_facet = function(id, title, description, content) {
return self._interpolateTemplate(settings.template_facet, {
id: id,
title: title,
description: description,
content: content,
});
};
/**
* Returns the "Single facet value" template string, filled with given variables.
*
* @param {string} facetId
* @param {string} value
* @param {string} count
* @return {string}
*/
this._theme_facet_value = function(facetId, value, count) {
const id = `${facetId}-${value}`.replace(/\W/g, "-").toLowerCase();
return self._interpolateTemplate(settings.template_facet_value, {
facetId: facetId,
id: id,
value: value,
count: count,
});
};
/**
* Returns the "Reset" template string, filled with given variables.
*
* @param {string} title
* @param {string} description
* @param {string} label
* @return {string}
*/
this._theme_facet_reset = function(title = "", description = "", label = "") {
const content = self._interpolateTemplate(settings.template_facet_reset, {
label: label,
});
const id = "csff-reset";
return self._theme_facet(id, title, description, content);
};
/**
* Returns the "search" template string, filled with given variables.
*
* @param {string} title
* @param {string} description
* @param {string} placeholder
* @return {string}
*/
this._theme_search = function(title = "", description = "", placeholder = "") {
const content = self._interpolateTemplate(settings.template_search, {
placeholder: placeholder,
});
const id = "csff-search";
return self._theme_facet(id, title, description, content);
};
/**
* Returns the "Empty results" template string, filled with given variables.
*
* @param {string} content
* @return {string}
*/
this._theme_results_empty = function(content) {
return self._interpolateTemplate(settings.template_results_empty, {
content: content,
});
};
/**
* Helper function to replace variables in the JavaScript templating syntax
* ${} by a key-values object.
*
* This does NOT execute / eval code, simply replaces strings.
*
* @param {string} templateString
* @param {Object} replacements Key-Value object with replacement texts.
* @return {string}
*/
this._interpolateTemplate = function(templateString, replacements) {
return Object.entries(replacements).reduce(
(result, [key, value]) => result.replaceAll(`$\{${key}}`, `${value}`),
templateString
);
};
// Call init and return self.
this.init();
return this;
}
