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. import { createFocusTrap } from 'focus-trap';
  6. /**
  7. * @param {HTMLElement} element DOM element for component instantiation and scope
  8. * @param {Object} options
  9. * @param {String} options.stickySelector Selector for sticky inpage navigation element
  10. * @param {String} options.containerSelector Selector for inpage navigation container element
  11. * @param {String} options.inPageList Selector for inpage navigation list element
  12. * @param {String} options.spySelector Selector for inpage navigation spied element
  13. * @param {String} options.toggleSelector Selector for inpage navigation trigger element
  14. * @param {String} options.linksSelector Selector for inpage navigation link element
  15. * @param {String} options.spyActiveContainer Selector for inpage navigation container to spy on element
  16. * @param {String} options.spyClass Selector to spy on
  17. * @param {String} options.spyTrigger
  18. * @param {Number} options.spyOffset
  19. * @param {Boolean} options.attachClickListener Whether or not to bind click events
  20. * @param {Boolean} options.attachKeyListener Whether or not to bind click events
  21. * @param {Boolean} options.attachResizeListener Whether or not to bind resize events
  22. * @param {Boolean} options.attachScrollListener Whether or not to bind scroll events
  23. */
  24. export class InpageNavigation {
  25. /**
  26. * @static
  27. * Shorthand for instance creation and initialisation.
  28. *
  29. * @param {HTMLElement} root DOM element for component instantiation and scope
  30. *
  31. * @return {InpageNavigation} An instance of InpageNavigation.
  32. */
  33. static autoInit(root, { INPAGE_NAVIGATION: defaultOptions = {} } = {}) {
  34. const inpageNavigation = new InpageNavigation(root, defaultOptions);
  35. inpageNavigation.init();
  36. root.ECLInpageNavigation = inpageNavigation;
  37. return inpageNavigation;
  38. }
  39. /**
  40. * An array of supported events for this component.
  41. *
  42. * @type {Array<string>}
  43. * @event onToggle
  44. * Triggered when the list is toggled in mobile
  45. * @event onClick
  46. * Triggered when an item is clicked
  47. * @memberof InpageNavigation
  48. */
  49. supportedEvents = ['onToggle', 'onClick'];
  50. constructor(
  51. element,
  52. {
  53. stickySelector = '[data-ecl-inpage-navigation]',
  54. containerSelector = '[data-ecl-inpage-navigation-container]',
  55. inPageList = '[data-ecl-inpage-navigation-list]',
  56. spySelector = '[data-ecl-inpage-navigation-link]',
  57. toggleSelector = '[data-ecl-inpage-navigation-trigger]',
  58. linksSelector = '[data-ecl-inpage-navigation-link]',
  59. spyActiveContainer = 'ecl-inpage-navigation--visible',
  60. spyOffset = 20,
  61. spyClass = 'ecl-inpage-navigation__item--active',
  62. spyTrigger = '[data-ecl-inpage-navigation-trigger-current]',
  63. attachClickListener = true,
  64. attachResizeListener = true,
  65. attachScrollListener = true,
  66. attachKeyListener = true,
  67. contentClass = 'inpage-navigation__heading--active',
  68. } = {},
  69. ) {
  70. // Check element
  71. if (!element || element.nodeType !== Node.ELEMENT_NODE) {
  72. throw new TypeError(
  73. 'DOM element should be given to initialize this widget.',
  74. );
  75. }
  76. this.element = element;
  77. this.eventManager = new EventManager();
  78. this.attachClickListener = attachClickListener;
  79. this.attachKeyListener = attachKeyListener;
  80. this.attachResizeListener = attachResizeListener;
  81. this.attachScrollListener = attachScrollListener;
  82. this.stickySelector = stickySelector;
  83. this.containerSelector = containerSelector;
  84. this.toggleSelector = toggleSelector;
  85. this.linksSelector = linksSelector;
  86. this.inPageList = inPageList;
  87. this.spyActiveContainer = spyActiveContainer;
  88. this.spySelector = spySelector;
  89. this.spyOffset = spyOffset;
  90. this.spyClass = spyClass;
  91. this.spyTrigger = spyTrigger;
  92. this.contentClass = contentClass;
  93. this.gumshoe = null;
  94. this.observer = null;
  95. this.stickyObserver = null;
  96. this.isExpanded = false;
  97. this.toggleElement = null;
  98. this.navLinks = null;
  99. this.resizeTimer = null;
  100. // Bind `this` for use in callbacks
  101. this.handleClickOnToggler = this.handleClickOnToggler.bind(this);
  102. this.handleClickOnLink = this.handleClickOnLink.bind(this);
  103. this.handleKeyboard = this.handleKeyboard.bind(this);
  104. this.initScrollSpy = this.initScrollSpy.bind(this);
  105. this.initObserver = this.initObserver.bind(this);
  106. this.activateScrollSpy = this.activateScrollSpy.bind(this);
  107. this.deactivateScrollSpy = this.deactivateScrollSpy.bind(this);
  108. this.destroySticky = this.destroySticky.bind(this);
  109. this.destroyScrollSpy = this.destroyScrollSpy.bind(this);
  110. this.destroyObserver = this.destroyObserver.bind(this);
  111. this.openList = this.openList.bind(this);
  112. this.closeList = this.closeList.bind(this);
  113. this.setListHeight = this.setListHeight.bind(this);
  114. this.handleResize = this.handleResize.bind(this);
  115. }
  116. // ACTIONS
  117. /**
  118. * Initiate sticky behaviors.
  119. */
  120. initSticky() {
  121. this.stickyInstance = new Stickyfill.Sticky(this.element);
  122. }
  123. /**
  124. * Destroy sticky behaviors.
  125. */
  126. destroySticky() {
  127. if (this.stickyInstance) {
  128. this.stickyInstance.remove();
  129. }
  130. }
  131. /**
  132. * Initiate scroll spy behaviors.
  133. */
  134. initScrollSpy() {
  135. this.gumshoe = new Gumshoe(this.spySelector, {
  136. navClass: this.spyClass,
  137. contentClass: this.contentClass,
  138. offset: this.spyOffset,
  139. reflow: true,
  140. });
  141. document.addEventListener('gumshoeActivate', this.activateScrollSpy, false);
  142. document.addEventListener(
  143. 'gumshoeDeactivate',
  144. this.deactivateScrollSpy,
  145. false,
  146. );
  147. if ('IntersectionObserver' in window) {
  148. const navigationContainer = queryOne(this.containerSelector);
  149. if (navigationContainer) {
  150. let previousY = 0;
  151. let previousRatio = 0;
  152. let initialized = false;
  153. this.stickyObserver = new IntersectionObserver(
  154. (entries) => {
  155. if (entries && entries[0]) {
  156. const entry = entries[0];
  157. const currentY = entry.boundingClientRect.y;
  158. const currentRatio = entry.intersectionRatio;
  159. const { isIntersecting } = entry;
  160. if (!initialized) {
  161. initialized = true;
  162. previousY = currentY;
  163. previousRatio = currentRatio;
  164. return;
  165. }
  166. if (currentY < previousY) {
  167. if (!(currentRatio > previousRatio && isIntersecting)) {
  168. // Scrolling down leave
  169. this.element.classList.remove(this.spyActiveContainer);
  170. }
  171. } else if (currentY > previousY && isIntersecting) {
  172. if (currentRatio > previousRatio) {
  173. // Scrolling up enter
  174. this.element.classList.add(this.spyActiveContainer);
  175. }
  176. }
  177. previousY = currentY;
  178. previousRatio = currentRatio;
  179. }
  180. },
  181. { root: null },
  182. );
  183. // observing a target element
  184. this.stickyObserver.observe(navigationContainer);
  185. }
  186. }
  187. }
  188. /**
  189. * Activate scroll spy behaviors.
  190. *
  191. * @param {Event} event
  192. */
  193. activateScrollSpy(event) {
  194. const navigationTitle = queryOne(this.spyTrigger);
  195. this.element.classList.add(this.spyActiveContainer);
  196. navigationTitle.textContent = event.detail.content.textContent;
  197. }
  198. /**
  199. * Deactivate scroll spy behaviors.
  200. */
  201. deactivateScrollSpy() {
  202. const navigationTitle = queryOne(this.spyTrigger);
  203. this.element.classList.remove(this.spyActiveContainer);
  204. navigationTitle.innerHTML = '';
  205. }
  206. /**
  207. * Destroy scroll spy behaviors.
  208. */
  209. destroyScrollSpy() {
  210. if (this.stickyObserver) {
  211. this.stickyObserver.disconnect();
  212. }
  213. document.removeEventListener(
  214. 'gumshoeActivate',
  215. this.activateScrollSpy,
  216. false,
  217. );
  218. document.removeEventListener(
  219. 'gumshoeDeactivate',
  220. this.deactivateScrollSpy,
  221. false,
  222. );
  223. this.gumshoe.destroy();
  224. }
  225. /**
  226. * Initiate observer.
  227. */
  228. initObserver() {
  229. if ('MutationObserver' in window) {
  230. const self = this;
  231. this.observer = new MutationObserver((mutationsList) => {
  232. const body = queryOne('.ecl-col-l-9');
  233. const currentInpage = queryOne('[data-ecl-inpage-navigation-list]');
  234. mutationsList.forEach((mutation) => {
  235. // Exclude the changes we perform.
  236. if (
  237. mutation &&
  238. mutation.target &&
  239. mutation.target.classList &&
  240. !mutation.target.classList.contains(
  241. 'ecl-inpage-navigation__trigger-current',
  242. )
  243. ) {
  244. // Added nodes.
  245. if (mutation.addedNodes.length > 0) {
  246. [].slice.call(mutation.addedNodes).forEach((addedNode) => {
  247. if (addedNode.tagName === 'H2' && addedNode.id) {
  248. const H2s = queryAll('h2[id]', body);
  249. const addedNodeIndex = H2s.findIndex(
  250. (H2) => H2.id === addedNode.id,
  251. );
  252. const element =
  253. currentInpage.childNodes[addedNodeIndex - 1].cloneNode(
  254. true,
  255. );
  256. element.childNodes[0].textContent = addedNode.textContent;
  257. element.childNodes[0].href = `#${addedNode.id}`;
  258. currentInpage.childNodes[addedNodeIndex - 1].after(element);
  259. }
  260. });
  261. }
  262. // Removed nodes.
  263. if (mutation.removedNodes.length > 0) {
  264. [].slice.call(mutation.removedNodes).forEach((removedNode) => {
  265. if (removedNode.tagName === 'H2' && removedNode.id) {
  266. currentInpage.childNodes.forEach((item) => {
  267. if (
  268. item.childNodes[0].href.indexOf(removedNode.id) !== -1
  269. ) {
  270. // Remove the element from the inpage.
  271. item.remove();
  272. }
  273. });
  274. }
  275. });
  276. }
  277. self.update();
  278. }
  279. });
  280. });
  281. this.observer.observe(document, {
  282. subtree: true,
  283. childList: true,
  284. });
  285. }
  286. }
  287. /**
  288. * Destroy observer.
  289. */
  290. destroyObserver() {
  291. if (this.observer) {
  292. this.observer.disconnect();
  293. }
  294. }
  295. /**
  296. * Initialise component.
  297. */
  298. init() {
  299. if (!ECL) {
  300. throw new TypeError('Called init but ECL is not present');
  301. }
  302. ECL.components = ECL.components || new Map();
  303. this.toggleElement = queryOne(this.toggleSelector, this.element);
  304. this.navLinks = queryAll(this.linksSelector, this.element);
  305. this.currentList = queryOne(this.inPageList, this.element);
  306. this.direction = getComputedStyle(this.element).direction;
  307. if (this.direction === 'rtl') {
  308. this.element.classList.add('ecl-inpage-navigation--rtl');
  309. }
  310. this.setListHeight();
  311. this.initSticky(this.element);
  312. this.initScrollSpy();
  313. this.initObserver();
  314. // Create focus trap
  315. this.focusTrap = createFocusTrap(this.element, {
  316. onActivate: () => this.openList(),
  317. onDeactivate: () => this.closeList(),
  318. });
  319. if (this.attachClickListener && this.toggleElement) {
  320. this.toggleElement.addEventListener('click', this.handleClickOnToggler);
  321. }
  322. if (this.attachResizeListener) {
  323. window.addEventListener('resize', this.handleResize);
  324. }
  325. if (this.attachScrollListener) {
  326. window.addEventListener('scroll', this.handleResize);
  327. }
  328. if (this.attachClickListener && this.navLinks) {
  329. this.navLinks.forEach((link) =>
  330. link.addEventListener('click', this.handleClickOnLink),
  331. );
  332. this.element.addEventListener('keydown', this.handleShiftTab);
  333. this.toggleElement.addEventListener('click', this.handleClickOnToggler);
  334. }
  335. document.addEventListener('keydown', this.handleKeyboard);
  336. // Set ecl initialized attribute
  337. this.element.setAttribute('data-ecl-auto-initialized', 'true');
  338. ECL.components.set(this.element, this);
  339. }
  340. /**
  341. * Register a callback function for a specific event.
  342. *
  343. * @param {string} eventName - The name of the event to listen for.
  344. * @param {Function} callback - The callback function to be invoked when the event occurs.
  345. * @returns {void}
  346. * @memberof InpageNavigation
  347. * @instance
  348. *
  349. * @example
  350. * // Registering a callback for the 'onToggle' event
  351. * inpage.on('onToggle', (event) => {
  352. * console.log('Toggle event occurred!', event);
  353. * });
  354. */
  355. on(eventName, callback) {
  356. this.eventManager.on(eventName, callback);
  357. }
  358. /**
  359. * Trigger a component event.
  360. *
  361. * @param {string} eventName - The name of the event to trigger.
  362. * @param {any} eventData - Data associated with the event.
  363. * @memberof InpageNavigation
  364. */
  365. trigger(eventName, eventData) {
  366. this.eventManager.trigger(eventName, eventData);
  367. }
  368. /**
  369. * Update scroll spy instance.
  370. */
  371. update() {
  372. this.gumshoe.setup();
  373. }
  374. /**
  375. * Open mobile list link.
  376. */
  377. openList() {
  378. this.currentList.classList.add('ecl-inpage-navigation__list--visible');
  379. this.toggleElement.setAttribute('aria-expanded', 'true');
  380. }
  381. /**
  382. * Close mobile list link.
  383. */
  384. closeList() {
  385. this.currentList.classList.remove('ecl-inpage-navigation__list--visible');
  386. this.toggleElement.setAttribute('aria-expanded', 'false');
  387. }
  388. /**
  389. * Calculate the available space for the dropwdown and set a max-height on the list
  390. */
  391. setListHeight() {
  392. const viewportHeight = window.innerHeight;
  393. const viewportWidth = window.innerWidth;
  394. const listTitle = queryOne('.ecl-inpage-navigation__title', this.element);
  395. let topPosition = 0;
  396. // Mobile
  397. setTimeout(() => {
  398. if (viewportWidth < 996) {
  399. const toggleWrapper = this.toggleElement.parentElement;
  400. if (toggleWrapper) {
  401. // EC has currently a negative margin set on the wrapper.
  402. topPosition =
  403. toggleWrapper.getBoundingClientRect().bottom +
  404. parseFloat(window.getComputedStyle(toggleWrapper).marginBottom);
  405. }
  406. } else if (listTitle) {
  407. // If we have a title in desktop
  408. topPosition = listTitle.getBoundingClientRect().bottom;
  409. } else {
  410. // Get the list position if there is no title
  411. topPosition = this.element.getBoundingClientRect().top;
  412. }
  413. const availableSpace = viewportHeight - topPosition;
  414. if (availableSpace > 0) {
  415. this.currentList.style.maxHeight = `${availableSpace}px`;
  416. }
  417. }, 100);
  418. }
  419. /**
  420. * Invoke event listeners on toggle click.
  421. *
  422. * @param {Event} e
  423. */
  424. handleClickOnToggler(e) {
  425. e.preventDefault();
  426. if (this.toggleElement) {
  427. // Get current status
  428. this.isExpanded =
  429. this.toggleElement.getAttribute('aria-expanded') === 'true';
  430. // Toggle the expandable/collapsible
  431. this.toggleElement.setAttribute(
  432. 'aria-expanded',
  433. this.isExpanded ? 'false' : 'true',
  434. );
  435. if (this.isExpanded) {
  436. // Untrap focus
  437. this.focusTrap.deactivate();
  438. } else {
  439. this.setListHeight();
  440. // Trap focus
  441. this.focusTrap.activate();
  442. // Focus first item
  443. if (this.navLinks && this.navLinks.length > 0) {
  444. this.navLinks[0].focus();
  445. }
  446. }
  447. this.trigger('onToggle', { isExpanded: this.isExpanded });
  448. }
  449. }
  450. /**
  451. * Sets the necessary attributes to collapse inpage navigation list.
  452. *
  453. * @param {Event} e
  454. */
  455. handleClickOnLink(e) {
  456. const { href } = e.target;
  457. let heading = null;
  458. if (href) {
  459. const id = href.split('#')[1];
  460. if (id) {
  461. heading = queryOne(`#${id}`, document);
  462. }
  463. }
  464. // Untrap focus
  465. this.focusTrap.deactivate();
  466. const eventData = { target: heading || href, e };
  467. this.trigger('onClick', eventData);
  468. }
  469. /**
  470. * Trigger events on resize
  471. * Uses a debounce, for performance
  472. */
  473. handleResize() {
  474. clearTimeout(this.resizeTimer);
  475. this.resizeTimer = setTimeout(() => {
  476. this.setListHeight();
  477. }, 100);
  478. }
  479. /**
  480. * Handle keyboard
  481. *
  482. * @param {Event} e
  483. */
  484. handleKeyboard(e) {
  485. const element = e.target;
  486. if (e.key === 'ArrowUp') {
  487. e.preventDefault();
  488. if (element === this.navLinks[0]) {
  489. this.handleClickOnToggler(e);
  490. } else {
  491. const prevItem = element.parentElement.previousSibling;
  492. if (
  493. prevItem &&
  494. prevItem.classList.contains('ecl-inpage-navigation__item')
  495. ) {
  496. const prevLink = queryOne(this.linksSelector, prevItem);
  497. if (prevLink) {
  498. prevLink.focus();
  499. }
  500. }
  501. }
  502. }
  503. if (e.key === 'ArrowDown') {
  504. e.preventDefault();
  505. if (element === this.toggleElement) {
  506. this.handleClickOnToggler(e);
  507. } else {
  508. const nextItem = element.parentElement.nextSibling;
  509. if (
  510. nextItem &&
  511. nextItem.classList.contains('ecl-inpage-navigation__item')
  512. ) {
  513. const nextLink = queryOne(this.linksSelector, nextItem);
  514. if (nextLink) {
  515. nextLink.focus();
  516. }
  517. }
  518. }
  519. }
  520. }
  521. /**
  522. * Destroy component instance.
  523. */
  524. destroy() {
  525. if (this.attachClickListener && this.toggleElement) {
  526. this.toggleElement.removeEventListener(
  527. 'click',
  528. this.handleClickOnToggler,
  529. );
  530. }
  531. if (this.attachClickListener && this.navLinks) {
  532. this.navLinks.forEach((link) =>
  533. link.removeEventListener('click', this.handleClickOnLink),
  534. );
  535. }
  536. if (this.attachKeyListener) {
  537. document.removeEventListener('keydown', this.handleKeyboard);
  538. }
  539. if (this.attachResizeListener) {
  540. window.removeEventListener('resize', this.handleResize);
  541. }
  542. this.destroyScrollSpy();
  543. this.destroySticky();
  544. this.destroyObserver();
  545. if (this.element) {
  546. this.element.removeAttribute('data-ecl-auto-initialized');
  547. ECL.components.delete(this.element);
  548. }
  549. }
  550. }
  551. export default InpageNavigation;