import { queryAll, queryOne } from '@ecl/dom-utils';
/**
* @param {HTMLElement} element DOM element for component instantiation and scope
* @param {Object} options
* @param {String} options.ellipsisButtonSelector
* @param {String} options.ellipsisSelector
* @param {String} options.segmentSelector
* @param {String} options.expandableItemsSelector
* @param {String} options.staticItemsSelector
* @param {Function} options.onPartialExpand
* @param {Function} options.onFullExpand
* @param {Boolean} options.attachClickListener
*/
export class Breadcrumb {
/**
* @static
* Shorthand for instance creation and initialisation.
*
* @param {HTMLElement} root DOM element for component instantiation and scope
*
* @return {Breadcrumb} An instance of Breadcrumb.
*/
static autoInit(root, { BREADCRUMB: defaultOptions = {} } = {}) {
const breadcrumb = new Breadcrumb(root, defaultOptions);
breadcrumb.init();
root.ECLBreadcrumb = breadcrumb;
return breadcrumb;
}
constructor(
element,
{
ellipsisButtonSelector = '[data-ecl-breadcrumb-ellipsis-button]',
ellipsisSelector = '[data-ecl-breadcrumb-ellipsis]',
segmentSelector = '[data-ecl-breadcrumb-item]',
expandableItemsSelector = '[data-ecl-breadcrumb-item="expandable"]',
staticItemsSelector = '[data-ecl-breadcrumb-item="static"]',
onPartialExpand = null,
onFullExpand = null,
attachClickListener = true,
attachResizeListener = true,
} = {},
) {
// Check element
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
throw new TypeError(
'DOM element should be given to initialize this widget.',
);
}
this.element = element;
// Options
this.ellipsisButtonSelector = ellipsisButtonSelector;
this.ellipsisSelector = ellipsisSelector;
this.segmentSelector = segmentSelector;
this.expandableItemsSelector = expandableItemsSelector;
this.staticItemsSelector = staticItemsSelector;
this.onPartialExpand = onPartialExpand;
this.onFullExpand = onFullExpand;
this.attachClickListener = attachClickListener;
this.attachResizeListener = attachResizeListener;
// Private variables
this.ellipsisButton = null;
this.itemsElements = null;
this.staticElements = null;
this.expandableElements = null;
this.resizeTimer = null;
// Bind `this` for use in callbacks
this.handleClickOnEllipsis = this.handleClickOnEllipsis.bind(this);
this.handleResize = this.handleResize.bind(this);
}
/**
* Initialise component.
*/
init() {
if (!ECL) {
throw new TypeError('Called init but ECL is not present');
}
ECL.components = ECL.components || new Map();
this.ellipsisButton = queryOne(this.ellipsisButtonSelector, this.element);
// Bind click event on ellipsis
if (this.attachClickListener && this.ellipsisButton) {
this.ellipsisButton.addEventListener('click', this.handleClickOnEllipsis);
}
this.itemsElements = queryAll(this.segmentSelector, this.element);
this.staticElements = queryAll(this.staticItemsSelector, this.element);
this.expandableElements = queryAll(
this.expandableItemsSelector,
this.element,
);
this.check();
// Bind resize events
if (this.attachResizeListener) {
window.addEventListener('resize', this.handleResize);
}
// Set ecl initialized attribute
this.element.setAttribute('data-ecl-auto-initialized', 'true');
ECL.components.set(this.element, this);
}
/**
* Destroy component.
*/
destroy() {
if (this.attachClickListener && this.ellipsisButton) {
this.ellipsisButton.removeEventListener(
'click',
this.handleClickOnEllipsis,
);
}
if (this.attachResizeListener) {
window.removeEventListener('resize', this.handleResize);
}
if (this.element) {
this.element.removeAttribute('data-ecl-auto-initialized');
this.element.classList.remove('ecl-breadcrumb--wrap');
ECL.components.delete(this.element);
}
}
/**
* Invoke event listener attached on the elipsis. Traslates to a full expand.
*/
handleClickOnEllipsis() {
return this.handleFullExpand();
}
/**
* Apply partial or full expand.
*/
async check() {
const visibilityMap = await this.computeVisibilityMap();
if (!visibilityMap) return;
if (visibilityMap.expanded === true) {
this.handleFullExpand();
} else {
this.handlePartialExpand(visibilityMap);
}
}
/**
* Removes the elipsis element and its event listeners.
*/
hideEllipsis() {
// Hide ellipsis
const ellipsis = queryOne(this.ellipsisSelector, this.element);
if (ellipsis) {
ellipsis.setAttribute('aria-hidden', 'true');
}
}
/**
* Show all expandable elements.
*/
showAllItems() {
this.expandableElements.forEach((item) =>
item.setAttribute('aria-hidden', 'false'),
);
}
/**
* @param {Object} visibilityMap
*/
handlePartialExpand(visibilityMap) {
if (!visibilityMap) return;
this.element.classList.add('ecl-breadcrumb--collapsed');
const { isItemVisible } = visibilityMap;
if (!isItemVisible || !Array.isArray(isItemVisible)) return;
if (this.onPartialExpand) {
this.onPartialExpand(isItemVisible);
} else {
// eslint-disable-next-line no-lonely-if
if (Math.floor(this.element.getBoundingClientRect().width) > 767) {
const ellipsis = queryOne(this.ellipsisSelector, this.element);
if (ellipsis) {
ellipsis.setAttribute('aria-hidden', 'false');
}
this.expandableElements.forEach((item, index) => {
item.setAttribute(
'aria-hidden',
isItemVisible[index] ? 'false' : 'true',
);
});
} else {
this.expandableElements.forEach((item) => {
item.setAttribute('aria-hidden', 'true');
});
}
}
}
/**
* Display all elements.
*/
handleFullExpand() {
this.element.classList.remove('ecl-breadcrumb--collapsed');
this.element.classList.add('ecl-breadcrumb--wrap');
if (this.onFullExpand) {
this.onFullExpand();
} else {
this.hideEllipsis();
this.showAllItems();
}
}
/**
* Trigger events on resize
*/
handleResize() {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
this.check();
}, 200);
}
/**
* Measure/evaluate which elements can be displayed and toggle those who don't fit.
*/
computeVisibilityMap() {
return new Promise((resolve) => {
// Ignore if there are no expandableElements
if (!this.expandableElements || this.expandableElements.length === 0) {
resolve({ expanded: true });
return;
}
const wrapperWidth = Math.floor(
this.element.getBoundingClientRect().width,
);
setTimeout(() => {
// Get the sum of all items' width
const allItemsWidth = this.itemsElements
.map((breadcrumbSegment) => {
let segmentWidth = breadcrumbSegment.getBoundingClientRect().width;
// Current page can have a display none set via the css.
if (segmentWidth === 0) {
breadcrumbSegment.style.display = 'inline-flex';
segmentWidth = breadcrumbSegment.getBoundingClientRect().width;
breadcrumbSegment.style.cssText = '';
}
return segmentWidth;
})
.reduce((a, b) => a + b);
// This calculation is not always 100% reliable, we add a 10% to limit the risk.
if (allItemsWidth * 1.1 <= wrapperWidth) {
resolve({ expanded: true });
return;
}
const ellipsisItem = queryOne(this.ellipsisSelector, this.element);
const ellipsisItemWidth = ellipsisItem.getBoundingClientRect().width;
const incompressibleWidth =
ellipsisItemWidth +
this.staticElements.reduce(
(sum, currentItem) =>
sum + currentItem.getBoundingClientRect().width,
0,
);
if (incompressibleWidth >= wrapperWidth) {
resolve({
expanded: false,
isItemVisible: [...this.expandableElements.map(() => false)],
});
return;
}
let previousItemsWidth = 0;
let isPreviousItemVisible = true;
// Careful: reverse() is destructive, that's why we make a copy of the array
const isItemVisible = [...this.expandableElements]
.reverse()
.map((otherSegment) => {
if (!isPreviousItemVisible) return false;
previousItemsWidth += otherSegment.getBoundingClientRect().width;
const isVisible =
previousItemsWidth + incompressibleWidth <= wrapperWidth;
if (!isVisible) isPreviousItemVisible = false;
return isVisible;
})
.reverse();
resolve({
expanded: false,
isItemVisible,
});
}, 150);
});
}
}
export default Breadcrumb;