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. this.element.classList.remove('ecl-breadcrumb--wrap');
  114. ECL.components.delete(this.element);
  115. }
  116. }
  117. /**
  118. * Invoke event listener attached on the elipsis. Traslates to a full expand.
  119. */
  120. handleClickOnEllipsis() {
  121. return this.handleFullExpand();
  122. }
  123. /**
  124. * Apply partial or full expand.
  125. */
  126. async check() {
  127. const visibilityMap = await this.computeVisibilityMap();
  128. if (!visibilityMap) return;
  129. if (visibilityMap.expanded === true) {
  130. this.handleFullExpand();
  131. } else {
  132. this.handlePartialExpand(visibilityMap);
  133. }
  134. }
  135. /**
  136. * Removes the elipsis element and its event listeners.
  137. */
  138. hideEllipsis() {
  139. // Hide ellipsis
  140. const ellipsis = queryOne(this.ellipsisSelector, this.element);
  141. if (ellipsis) {
  142. ellipsis.setAttribute('aria-hidden', 'true');
  143. }
  144. }
  145. /**
  146. * Show all expandable elements.
  147. */
  148. showAllItems() {
  149. this.expandableElements.forEach((item) =>
  150. item.setAttribute('aria-hidden', 'false'),
  151. );
  152. }
  153. /**
  154. * @param {Object} visibilityMap
  155. */
  156. handlePartialExpand(visibilityMap) {
  157. if (!visibilityMap) return;
  158. this.element.classList.add('ecl-breadcrumb--collapsed');
  159. const { isItemVisible } = visibilityMap;
  160. if (!isItemVisible || !Array.isArray(isItemVisible)) return;
  161. if (this.onPartialExpand) {
  162. this.onPartialExpand(isItemVisible);
  163. } else {
  164. // eslint-disable-next-line no-lonely-if
  165. if (Math.floor(this.element.getBoundingClientRect().width) > 767) {
  166. const ellipsis = queryOne(this.ellipsisSelector, this.element);
  167. if (ellipsis) {
  168. ellipsis.setAttribute('aria-hidden', 'false');
  169. }
  170. this.expandableElements.forEach((item, index) => {
  171. item.setAttribute(
  172. 'aria-hidden',
  173. isItemVisible[index] ? 'false' : 'true',
  174. );
  175. });
  176. } else {
  177. this.expandableElements.forEach((item) => {
  178. item.setAttribute('aria-hidden', 'true');
  179. });
  180. }
  181. }
  182. }
  183. /**
  184. * Display all elements.
  185. */
  186. handleFullExpand() {
  187. this.element.classList.remove('ecl-breadcrumb--collapsed');
  188. this.element.classList.add('ecl-breadcrumb--wrap');
  189. if (this.onFullExpand) {
  190. this.onFullExpand();
  191. } else {
  192. this.hideEllipsis();
  193. this.showAllItems();
  194. }
  195. }
  196. /**
  197. * Trigger events on resize
  198. */
  199. handleResize() {
  200. clearTimeout(this.resizeTimer);
  201. this.resizeTimer = setTimeout(() => {
  202. this.check();
  203. }, 200);
  204. }
  205. /**
  206. * Measure/evaluate which elements can be displayed and toggle those who don't fit.
  207. */
  208. computeVisibilityMap() {
  209. return new Promise((resolve) => {
  210. // Ignore if there are no expandableElements
  211. if (!this.expandableElements || this.expandableElements.length === 0) {
  212. resolve({ expanded: true });
  213. return;
  214. }
  215. const wrapperWidth = Math.floor(
  216. this.element.getBoundingClientRect().width,
  217. );
  218. setTimeout(() => {
  219. // Get the sum of all items' width
  220. const allItemsWidth = this.itemsElements
  221. .map((breadcrumbSegment) => {
  222. let segmentWidth = breadcrumbSegment.getBoundingClientRect().width;
  223. // Current page can have a display none set via the css.
  224. if (segmentWidth === 0) {
  225. breadcrumbSegment.style.display = 'inline-flex';
  226. segmentWidth = breadcrumbSegment.getBoundingClientRect().width;
  227. breadcrumbSegment.style.cssText = '';
  228. }
  229. return segmentWidth;
  230. })
  231. .reduce((a, b) => a + b);
  232. // This calculation is not always 100% reliable, we add a 10% to limit the risk.
  233. if (allItemsWidth * 1.1 <= wrapperWidth) {
  234. resolve({ expanded: true });
  235. return;
  236. }
  237. const ellipsisItem = queryOne(this.ellipsisSelector, this.element);
  238. const ellipsisItemWidth = ellipsisItem.getBoundingClientRect().width;
  239. const incompressibleWidth =
  240. ellipsisItemWidth +
  241. this.staticElements.reduce(
  242. (sum, currentItem) =>
  243. sum + currentItem.getBoundingClientRect().width,
  244. 0,
  245. );
  246. if (incompressibleWidth >= wrapperWidth) {
  247. resolve({
  248. expanded: false,
  249. isItemVisible: [...this.expandableElements.map(() => false)],
  250. });
  251. return;
  252. }
  253. let previousItemsWidth = 0;
  254. let isPreviousItemVisible = true;
  255. // Careful: reverse() is destructive, that's why we make a copy of the array
  256. const isItemVisible = [...this.expandableElements]
  257. .reverse()
  258. .map((otherSegment) => {
  259. if (!isPreviousItemVisible) return false;
  260. previousItemsWidth += otherSegment.getBoundingClientRect().width;
  261. const isVisible =
  262. previousItemsWidth + incompressibleWidth <= wrapperWidth;
  263. if (!isVisible) isPreviousItemVisible = false;
  264. return isVisible;
  265. })
  266. .reverse();
  267. resolve({
  268. expanded: false,
  269. isItemVisible,
  270. });
  271. }, 150);
  272. });
  273. }
  274. }
  275. export default Breadcrumb;