breadcrumb.js

  1. import { queryAll, queryOne } from '@ecl/dom-utils';
  2. /**
  3. * @param {HTMLElement} element DOM element for component instantiation and scope
  4. * @param {Object} options
  5. * @param {String} options.ellipsisButtonSelector
  6. * @param {String} options.ellipsisSelector
  7. * @param {String} options.segmentSelector
  8. * @param {String} options.expandableItemsSelector
  9. * @param {String} options.staticItemsSelector
  10. * @param {Function} options.onPartialExpand
  11. * @param {Function} options.onFullExpand
  12. * @param {Boolean} options.attachClickListener
  13. */
  14. export class Breadcrumb {
  15. /**
  16. * @static
  17. * Shorthand for instance creation and initialisation.
  18. *
  19. * @param {HTMLElement} root DOM element for component instantiation and scope
  20. *
  21. * @return {Breadcrumb} An instance of Breadcrumb.
  22. */
  23. static autoInit(root, { BREADCRUMB: defaultOptions = {} } = {}) {
  24. const breadcrumb = new Breadcrumb(root, defaultOptions);
  25. breadcrumb.init();
  26. root.ECLBreadcrumb = breadcrumb;
  27. return breadcrumb;
  28. }
  29. constructor(
  30. element,
  31. {
  32. ellipsisButtonSelector = '[data-ecl-breadcrumb-ellipsis-button]',
  33. ellipsisSelector = '[data-ecl-breadcrumb-ellipsis]',
  34. segmentSelector = '[data-ecl-breadcrumb-item]',
  35. expandableItemsSelector = '[data-ecl-breadcrumb-item="expandable"]',
  36. staticItemsSelector = '[data-ecl-breadcrumb-item="static"]',
  37. onPartialExpand = null,
  38. onFullExpand = null,
  39. attachClickListener = true,
  40. attachResizeListener = true,
  41. } = {},
  42. ) {
  43. // Check element
  44. if (!element || element.nodeType !== Node.ELEMENT_NODE) {
  45. throw new TypeError(
  46. 'DOM element should be given to initialize this widget.',
  47. );
  48. }
  49. this.element = element;
  50. // Options
  51. this.ellipsisButtonSelector = ellipsisButtonSelector;
  52. this.ellipsisSelector = ellipsisSelector;
  53. this.segmentSelector = segmentSelector;
  54. this.expandableItemsSelector = expandableItemsSelector;
  55. this.staticItemsSelector = staticItemsSelector;
  56. this.onPartialExpand = onPartialExpand;
  57. this.onFullExpand = onFullExpand;
  58. this.attachClickListener = attachClickListener;
  59. this.attachResizeListener = attachResizeListener;
  60. // Private variables
  61. this.ellipsisButton = null;
  62. this.itemsElements = null;
  63. this.staticElements = null;
  64. this.expandableElements = null;
  65. this.resizeTimer = null;
  66. // Bind `this` for use in callbacks
  67. this.handleClickOnEllipsis = this.handleClickOnEllipsis.bind(this);
  68. this.handleResize = this.handleResize.bind(this);
  69. }
  70. /**
  71. * Initialise component.
  72. */
  73. init() {
  74. if (!ECL) {
  75. throw new TypeError('Called init but ECL is not present');
  76. }
  77. ECL.components = ECL.components || new Map();
  78. this.ellipsisButton = queryOne(this.ellipsisButtonSelector, this.element);
  79. // Bind click event on ellipsis
  80. if (this.attachClickListener && this.ellipsisButton) {
  81. this.ellipsisButton.addEventListener('click', this.handleClickOnEllipsis);
  82. }
  83. this.itemsElements = queryAll(this.segmentSelector, this.element);
  84. this.staticElements = queryAll(this.staticItemsSelector, this.element);
  85. this.expandableElements = queryAll(
  86. this.expandableItemsSelector,
  87. this.element,
  88. );
  89. this.check();
  90. // Bind resize events
  91. if (this.attachResizeListener) {
  92. window.addEventListener('resize', this.handleResize);
  93. }
  94. // Set ecl initialized attribute
  95. this.element.setAttribute('data-ecl-auto-initialized', 'true');
  96. ECL.components.set(this.element, this);
  97. }
  98. /**
  99. * Destroy component.
  100. */
  101. destroy() {
  102. if (this.attachClickListener && this.ellipsisButton) {
  103. this.ellipsisButton.removeEventListener(
  104. 'click',
  105. this.handleClickOnEllipsis,
  106. );
  107. }
  108. if (this.attachResizeListener) {
  109. window.removeEventListener('resize', this.handleResize);
  110. }
  111. if (this.element) {
  112. this.element.removeAttribute('data-ecl-auto-initialized');
  113. ECL.components.delete(this.element);
  114. }
  115. }
  116. /**
  117. * Invoke event listener attached on the elipsis. Traslates to a full expand.
  118. */
  119. handleClickOnEllipsis() {
  120. return this.handleFullExpand();
  121. }
  122. /**
  123. * Apply partial or full expand.
  124. */
  125. check() {
  126. const visibilityMap = this.computeVisibilityMap();
  127. if (!visibilityMap) return;
  128. if (visibilityMap.expanded === true) {
  129. this.handleFullExpand();
  130. } else {
  131. this.handlePartialExpand(visibilityMap);
  132. }
  133. }
  134. /**
  135. * Removes the elipsis element and its event listeners.
  136. */
  137. hideEllipsis() {
  138. // Hide ellipsis
  139. const ellipsis = queryOne(this.ellipsisSelector, this.element);
  140. if (ellipsis) {
  141. ellipsis.setAttribute('aria-hidden', 'true');
  142. }
  143. }
  144. /**
  145. * Show all expandable elements.
  146. */
  147. showAllItems() {
  148. this.expandableElements.forEach((item) =>
  149. item.setAttribute('aria-hidden', 'false'),
  150. );
  151. }
  152. /**
  153. * @param {Object} visibilityMap
  154. */
  155. handlePartialExpand(visibilityMap) {
  156. if (!visibilityMap) return;
  157. this.element.classList.add('ecl-breadcrumb--collapsed');
  158. const { isItemVisible } = visibilityMap;
  159. if (!isItemVisible || !Array.isArray(isItemVisible)) return;
  160. if (this.onPartialExpand) {
  161. this.onPartialExpand(isItemVisible);
  162. } else {
  163. // eslint-disable-next-line no-lonely-if
  164. if (Math.floor(this.element.getBoundingClientRect().width) > 767) {
  165. const ellipsis = queryOne(this.ellipsisSelector, this.element);
  166. if (ellipsis) {
  167. ellipsis.setAttribute('aria-hidden', 'false');
  168. }
  169. this.expandableElements.forEach((item, index) => {
  170. item.setAttribute(
  171. 'aria-hidden',
  172. isItemVisible[index] ? 'false' : 'true',
  173. );
  174. });
  175. } else {
  176. this.expandableElements.forEach((item) => {
  177. item.setAttribute('aria-hidden', 'true');
  178. });
  179. }
  180. }
  181. }
  182. /**
  183. * Display all elements.
  184. */
  185. handleFullExpand() {
  186. this.element.classList.remove('ecl-breadcrumb--collapsed');
  187. if (this.onFullExpand) {
  188. this.onFullExpand();
  189. } else {
  190. this.hideEllipsis();
  191. this.showAllItems();
  192. }
  193. }
  194. /**
  195. * Trigger events on resize
  196. */
  197. handleResize() {
  198. clearTimeout(this.resizeTimer);
  199. this.resizeTimer = setTimeout(() => {
  200. this.check();
  201. }, 200);
  202. }
  203. /**
  204. * Measure/evaluate which elements can be displayed and toggle those who don't fit.
  205. */
  206. computeVisibilityMap() {
  207. // Ignore if there are no expandableElements
  208. if (!this.expandableElements || this.expandableElements.length === 0) {
  209. return { expanded: true };
  210. }
  211. const wrapperWidth = Math.floor(this.element.getBoundingClientRect().width);
  212. // Get the sum of all items' width
  213. const allItemsWidth = this.itemsElements
  214. .map((breadcrumbSegment) => {
  215. let segmentWidth = breadcrumbSegment.getBoundingClientRect().width;
  216. // Current page can have a display none set via the css.
  217. if (segmentWidth === 0) {
  218. breadcrumbSegment.style.display = 'inline-flex';
  219. segmentWidth = breadcrumbSegment.getBoundingClientRect().width;
  220. breadcrumbSegment.style.cssText = '';
  221. }
  222. return segmentWidth;
  223. })
  224. .reduce((a, b) => a + b);
  225. // This calculation is not always 100% reliable, we add a 20% to limit the risk.
  226. if (allItemsWidth * 1.2 <= wrapperWidth) {
  227. return { expanded: true };
  228. }
  229. const ellipsisItem = queryOne(this.ellipsisSelector, this.element);
  230. const ellipsisItemWidth = ellipsisItem.getBoundingClientRect().width;
  231. const incompressibleWidth =
  232. ellipsisItemWidth +
  233. this.staticElements.reduce(
  234. (sum, currentItem) => sum + currentItem.getBoundingClientRect().width,
  235. 0,
  236. );
  237. if (incompressibleWidth >= wrapperWidth) {
  238. return {
  239. expanded: false,
  240. isItemVisible: [...this.expandableElements.map(() => false)],
  241. };
  242. }
  243. let previousItemsWidth = 0;
  244. let isPreviousItemVisible = true;
  245. // Careful: reverse() is destructive, that's why we make a copy of the array
  246. const isItemVisible = [...this.expandableElements]
  247. .reverse()
  248. .map((otherSegment) => {
  249. if (!isPreviousItemVisible) return false;
  250. previousItemsWidth += otherSegment.getBoundingClientRect().width;
  251. const isVisible =
  252. previousItemsWidth + incompressibleWidth <= wrapperWidth;
  253. if (!isVisible) isPreviousItemVisible = false;
  254. return isVisible;
  255. })
  256. .reverse();
  257. return {
  258. expanded: false,
  259. isItemVisible,
  260. };
  261. }
  262. }
  263. export default Breadcrumb;