inpage-navigation.js

  1. import Stickyfill from 'stickyfilljs';
  2. import Gumshoe from 'gumshoejs/dist/gumshoe.polyfills';
  3. import { queryOne, queryAll } from '@ecl/dom-utils';
  4. import EventManager from '@ecl/event-manager';
  5. /**
  6. * @param {HTMLElement} element DOM element for component instantiation and scope
  7. * @param {Object} options
  8. * @param {String} options.stickySelector Selector for sticky inpage navigation element
  9. * @param {String} options.containerSelector Selector for inpage navigation container element
  10. * @param {String} options.inPageList Selector for inpage navigation list element
  11. * @param {String} options.spySelector Selector for inpage navigation spied element
  12. * @param {String} options.toggleSelector Selector for inpage navigation trigger element
  13. * @param {String} options.linksSelector Selector for inpage navigation link element
  14. * @param {String} options.spyActiveContainer Selector for inpage navigation container to spy on element
  15. * @param {String} options.spyClass Selector to spy on
  16. * @param {String} options.spyTrigger
  17. * @param {Number} options.spyOffset
  18. * @param {Boolean} options.attachClickListener Whether or not to bind click events
  19. */
  20. export class InpageNavigation {
  21. /**
  22. * @static
  23. * Shorthand for instance creation and initialisation.
  24. *
  25. * @param {HTMLElement} root DOM element for component instantiation and scope
  26. *
  27. * @return {InpageNavigation} An instance of InpageNavigation.
  28. */
  29. static autoInit(root, { INPAGE_NAVIGATION: defaultOptions = {} } = {}) {
  30. const inpageNavigation = new InpageNavigation(root, defaultOptions);
  31. inpageNavigation.init();
  32. root.ECLInpageNavigation = inpageNavigation;
  33. return inpageNavigation;
  34. }
  35. /**
  36. * An array of supported events for this component.
  37. *
  38. * @type {Array<string>}
  39. * @event onToggle
  40. * Triggered when the list is toggled in mobile
  41. * @event onClick
  42. * Triggered when an item is clicked
  43. * @memberof InpageNavigation
  44. */
  45. supportedEvents = ['onToggle', 'onClick'];
  46. constructor(
  47. element,
  48. {
  49. stickySelector = '[data-ecl-inpage-navigation]',
  50. containerSelector = '[data-ecl-inpage-navigation-container]',
  51. inPageList = '[data-ecl-inpage-navigation-list]',
  52. spySelector = '[data-ecl-inpage-navigation-link]',
  53. toggleSelector = '[data-ecl-inpage-navigation-trigger]',
  54. linksSelector = '[data-ecl-inpage-navigation-link]',
  55. spyActiveContainer = 'ecl-inpage-navigation--visible',
  56. spyOffset = 20,
  57. spyClass = 'ecl-inpage-navigation__item--active',
  58. spyTrigger = '[data-ecl-inpage-navigation-trigger-current]',
  59. attachClickListener = true,
  60. contentClass = 'ecl-inpage-navigation__heading--active',
  61. } = {},
  62. ) {
  63. // Check element
  64. if (!element || element.nodeType !== Node.ELEMENT_NODE) {
  65. throw new TypeError(
  66. 'DOM element should be given to initialize this widget.',
  67. );
  68. }
  69. this.element = element;
  70. this.eventManager = new EventManager();
  71. this.attachClickListener = attachClickListener;
  72. this.stickySelector = stickySelector;
  73. this.containerSelector = containerSelector;
  74. this.toggleSelector = toggleSelector;
  75. this.linksSelector = linksSelector;
  76. this.inPageList = inPageList;
  77. this.spyActiveContainer = spyActiveContainer;
  78. this.spySelector = spySelector;
  79. this.spyOffset = spyOffset;
  80. this.spyClass = spyClass;
  81. this.spyTrigger = spyTrigger;
  82. this.contentClass = contentClass;
  83. this.gumshoe = null;
  84. this.observer = null;
  85. this.stickyObserver = null;
  86. this.isExpanded = false;
  87. // Bind `this` for use in callbacks
  88. this.handleClickOnToggler = this.handleClickOnToggler.bind(this);
  89. this.handleClickOnLink = this.handleClickOnLink.bind(this);
  90. this.initScrollSpy = this.initScrollSpy.bind(this);
  91. this.initObserver = this.initObserver.bind(this);
  92. this.handleEsc = this.handleEsc.bind(this);
  93. this.handleShiftTab = this.handleShiftTab.bind(this);
  94. this.activateScrollSpy = this.activateScrollSpy.bind(this);
  95. this.deactivateScrollSpy = this.deactivateScrollSpy.bind(this);
  96. this.destroySticky = this.destroySticky.bind(this);
  97. this.destroyScrollSpy = this.destroyScrollSpy.bind(this);
  98. this.destroyObserver = this.destroyObserver.bind(this);
  99. }
  100. // ACTIONS
  101. /**
  102. * Initiate sticky behaviors.
  103. */
  104. initSticky() {
  105. this.stickyInstance = new Stickyfill.Sticky(this.element);
  106. }
  107. /**
  108. * Destroy sticky behaviors.
  109. */
  110. destroySticky() {
  111. if (this.stickyInstance) {
  112. this.stickyInstance.remove();
  113. }
  114. }
  115. /**
  116. * Initiate scroll spy behaviors.
  117. */
  118. initScrollSpy() {
  119. this.gumshoe = new Gumshoe(this.spySelector, {
  120. navClass: this.spyClass,
  121. contentClass: this.contentClass,
  122. offset: this.spyOffset,
  123. reflow: true,
  124. });
  125. document.addEventListener('gumshoeActivate', this.activateScrollSpy, false);
  126. document.addEventListener(
  127. 'gumshoeDeactivate',
  128. this.deactivateScrollSpy,
  129. false,
  130. );
  131. if ('IntersectionObserver' in window) {
  132. const navigationContainer = queryOne(this.containerSelector);
  133. if (navigationContainer) {
  134. let previousY = 0;
  135. let previousRatio = 0;
  136. let initialized = false;
  137. this.stickyObserver = new IntersectionObserver(
  138. (entries) => {
  139. if (entries && entries[0]) {
  140. const entry = entries[0];
  141. const currentY = entry.boundingClientRect.y;
  142. const currentRatio = entry.intersectionRatio;
  143. const { isIntersecting } = entry;
  144. if (!initialized) {
  145. initialized = true;
  146. previousY = currentY;
  147. previousRatio = currentRatio;
  148. return;
  149. }
  150. if (currentY < previousY) {
  151. if (!(currentRatio > previousRatio && isIntersecting)) {
  152. // Scrolling down leave
  153. this.element.classList.remove(this.spyActiveContainer);
  154. }
  155. } else if (currentY > previousY && isIntersecting) {
  156. if (currentRatio > previousRatio) {
  157. // Scrolling up enter
  158. this.element.classList.add(this.spyActiveContainer);
  159. }
  160. }
  161. previousY = currentY;
  162. previousRatio = currentRatio;
  163. }
  164. },
  165. { root: null },
  166. );
  167. // observing a target element
  168. this.stickyObserver.observe(navigationContainer);
  169. }
  170. }
  171. }
  172. /**
  173. * Activate scroll spy behaviors.
  174. *
  175. * @param {Event} event
  176. */
  177. activateScrollSpy(event) {
  178. const navigationTitle = queryOne(this.spyTrigger);
  179. this.element.classList.add(this.spyActiveContainer);
  180. navigationTitle.textContent = event.detail.content.textContent;
  181. }
  182. /**
  183. * Deactivate scroll spy behaviors.
  184. */
  185. deactivateScrollSpy() {
  186. const navigationTitle = queryOne(this.spyTrigger);
  187. this.element.classList.remove(this.spyActiveContainer);
  188. navigationTitle.innerHTML = '';
  189. }
  190. /**
  191. * Destroy scroll spy behaviors.
  192. */
  193. destroyScrollSpy() {
  194. if (this.stickyObserver) {
  195. this.stickyObserver.disconnect();
  196. }
  197. document.removeEventListener(
  198. 'gumshoeActivate',
  199. this.activateScrollSpy,
  200. false,
  201. );
  202. document.removeEventListener(
  203. 'gumshoeDeactivate',
  204. this.deactivateScrollSpy,
  205. false,
  206. );
  207. this.gumshoe.destroy();
  208. }
  209. /**
  210. * Initiate observer.
  211. */
  212. initObserver() {
  213. if ('MutationObserver' in window) {
  214. const self = this;
  215. this.observer = new MutationObserver((mutationsList) => {
  216. const body = queryOne('.ecl-col-l-9');
  217. const currentInpage = queryOne('[data-ecl-inpage-navigation-list]');
  218. mutationsList.forEach((mutation) => {
  219. // Exclude the changes we perform.
  220. if (
  221. mutation &&
  222. mutation.target &&
  223. mutation.target.classList &&
  224. !mutation.target.classList.contains(
  225. 'ecl-inpage-navigation__trigger-current',
  226. )
  227. ) {
  228. // Added nodes.
  229. if (mutation.addedNodes.length > 0) {
  230. [].slice.call(mutation.addedNodes).forEach((addedNode) => {
  231. if (addedNode.tagName === 'H2' && addedNode.id) {
  232. const H2s = queryAll('h2[id]', body);
  233. const addedNodeIndex = H2s.findIndex(
  234. (H2) => H2.id === addedNode.id,
  235. );
  236. const element =
  237. currentInpage.childNodes[addedNodeIndex - 1].cloneNode(
  238. true,
  239. );
  240. element.childNodes[0].textContent = addedNode.textContent;
  241. element.childNodes[0].href = `#${addedNode.id}`;
  242. currentInpage.childNodes[addedNodeIndex - 1].after(element);
  243. }
  244. });
  245. }
  246. // Removed nodes.
  247. if (mutation.removedNodes.length > 0) {
  248. [].slice.call(mutation.removedNodes).forEach((removedNode) => {
  249. if (removedNode.tagName === 'H2' && removedNode.id) {
  250. currentInpage.childNodes.forEach((item) => {
  251. if (
  252. item.childNodes[0].href.indexOf(removedNode.id) !== -1
  253. ) {
  254. // Remove the element from the inpage.
  255. item.remove();
  256. }
  257. });
  258. }
  259. });
  260. }
  261. self.update();
  262. }
  263. });
  264. });
  265. this.observer.observe(document, {
  266. subtree: true,
  267. childList: true,
  268. });
  269. }
  270. }
  271. /**
  272. * Destroy observer.
  273. */
  274. destroyObserver() {
  275. if (this.observer) {
  276. this.observer.disconnect();
  277. }
  278. }
  279. /**
  280. * Initialise component.
  281. */
  282. init() {
  283. if (!ECL) {
  284. throw new TypeError('Called init but ECL is not present');
  285. }
  286. ECL.components = ECL.components || new Map();
  287. const toggleElement = queryOne(this.toggleSelector, this.element);
  288. const navLinks = queryAll(this.linksSelector, this.element);
  289. this.initSticky(this.element);
  290. this.initScrollSpy();
  291. this.initObserver();
  292. if (this.attachClickListener && toggleElement) {
  293. toggleElement.addEventListener('click', this.handleClickOnToggler);
  294. }
  295. if (this.attachClickListener && navLinks) {
  296. navLinks.forEach((link) =>
  297. link.addEventListener('click', this.handleClickOnLink),
  298. );
  299. this.element.addEventListener('keydown', this.handleShiftTab);
  300. toggleElement.addEventListener('click', this.handleClickOnToggler);
  301. }
  302. document.addEventListener('keydown', this.handleEsc);
  303. // Set ecl initialized attribute
  304. this.element.setAttribute('data-ecl-auto-initialized', 'true');
  305. ECL.components.set(this.element, this);
  306. }
  307. /**
  308. * Register a callback function for a specific event.
  309. *
  310. * @param {string} eventName - The name of the event to listen for.
  311. * @param {Function} callback - The callback function to be invoked when the event occurs.
  312. * @returns {void}
  313. * @memberof InpageNavigation
  314. * @instance
  315. *
  316. * @example
  317. * // Registering a callback for the 'onToggle' event
  318. * inpage.on('onToggle', (event) => {
  319. * console.log('Toggle event occurred!', event);
  320. * });
  321. */
  322. on(eventName, callback) {
  323. this.eventManager.on(eventName, callback);
  324. }
  325. /**
  326. * Trigger a component event.
  327. *
  328. * @param {string} eventName - The name of the event to trigger.
  329. * @param {any} eventData - Data associated with the event.
  330. * @memberof InpageNavigation
  331. */
  332. trigger(eventName, eventData) {
  333. this.eventManager.trigger(eventName, eventData);
  334. }
  335. /**
  336. * Update scroll spy instance.
  337. */
  338. update() {
  339. this.gumshoe.setup();
  340. }
  341. /**
  342. * Invoke event listeners on toggle click.
  343. *
  344. * @param {Event} e
  345. */
  346. handleClickOnToggler(e) {
  347. const currentList = queryOne(this.inPageList, this.element);
  348. const togglerElement = queryOne(this.toggleSelector, this.element);
  349. e.preventDefault();
  350. // Get current status
  351. this.isExpanded = togglerElement.getAttribute('aria-expanded') === 'true';
  352. // Toggle the expandable/collapsible
  353. togglerElement.setAttribute(
  354. 'aria-expanded',
  355. this.isExpanded ? 'false' : 'true',
  356. );
  357. if (this.isExpanded) {
  358. currentList.classList.remove('ecl-inpage-navigation__list--visible');
  359. } else {
  360. currentList.classList.add('ecl-inpage-navigation__list--visible');
  361. }
  362. this.trigger('onToggle', { isExpanded: this.isExpanded });
  363. }
  364. /**
  365. * Sets the necessary attributes to collapse inpage navigation list.
  366. *
  367. * @param {Event} e
  368. */
  369. handleClickOnLink(e) {
  370. const currentList = queryOne(this.inPageList, this.element);
  371. const togglerElement = queryOne(this.toggleSelector, this.element);
  372. const { href } = e.target;
  373. let heading = null;
  374. if (href) {
  375. const id = href.split('#')[1];
  376. if (id) {
  377. heading = queryOne(`#${id}`, document);
  378. }
  379. }
  380. currentList.classList.remove('ecl-inpage-navigation__list--visible');
  381. togglerElement.setAttribute('aria-expanded', 'false');
  382. const eventData = { target: heading || href, e };
  383. this.trigger('onClick', eventData);
  384. }
  385. /**
  386. * @param {Event} e
  387. */
  388. handleEsc(e) {
  389. if (e.key === 'Escape') {
  390. this.handleClickOnLink();
  391. }
  392. }
  393. /**
  394. * @param {Event} e
  395. */
  396. handleShiftTab(e) {
  397. if (e.key === 'Tab' && e.shiftKey) {
  398. const links = queryAll(this.linksSelector, this.element);
  399. if (Array.isArray(links) && links.length > 0 && e.target === links[0]) {
  400. this.handleClickOnLink();
  401. }
  402. }
  403. }
  404. /**
  405. * Destroy component instance.
  406. */
  407. destroy() {
  408. if (this.attachClickListener && this.toggleElement) {
  409. this.toggleElement.removeEventListener(
  410. 'click',
  411. this.handleClickOnToggler,
  412. );
  413. }
  414. if (this.attachClickListener && this.navLinks) {
  415. this.navLinks.forEach((link) =>
  416. link.removeEventListener('click', this.handleClickOnLink),
  417. );
  418. }
  419. this.destroyScrollSpy();
  420. this.destroySticky();
  421. this.destroyObserver();
  422. document.removeEventListener('keydown', this.handleEsc);
  423. if (this.element) {
  424. this.element.removeEventListener('keydown', this.handleShiftTab);
  425. this.element.removeAttribute('data-ecl-auto-initialized');
  426. ECL.components.delete(this.element);
  427. }
  428. }
  429. }
  430. export default InpageNavigation;