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