import Stickyfill from 'stickyfilljs';
import Gumshoe from 'gumshoejs/dist/gumshoe.polyfills';
import { queryOne, queryAll } from '@ecl/dom-utils';
import EventManager from '@ecl/event-manager';
/**
* @param {HTMLElement} element DOM element for component instantiation and scope
* @param {Object} options
* @param {String} options.stickySelector Selector for sticky inpage navigation element
* @param {String} options.containerSelector Selector for inpage navigation container element
* @param {String} options.inPageList Selector for inpage navigation list element
* @param {String} options.spySelector Selector for inpage navigation spied element
* @param {String} options.toggleSelector Selector for inpage navigation trigger element
* @param {String} options.linksSelector Selector for inpage navigation link element
* @param {String} options.spyActiveContainer Selector for inpage navigation container to spy on element
* @param {String} options.spyClass Selector to spy on
* @param {String} options.spyTrigger
* @param {Number} options.spyOffset
* @param {Boolean} options.attachClickListener Whether or not to bind click events
*/
export class InpageNavigation {
/**
* @static
* Shorthand for instance creation and initialisation.
*
* @param {HTMLElement} root DOM element for component instantiation and scope
*
* @return {InpageNavigation} An instance of InpageNavigation.
*/
static autoInit(root, { INPAGE_NAVIGATION: defaultOptions = {} } = {}) {
const inpageNavigation = new InpageNavigation(root, defaultOptions);
inpageNavigation.init();
root.ECLInpageNavigation = inpageNavigation;
return inpageNavigation;
}
/**
* An array of supported events for this component.
*
* @type {Array<string>}
* @event onToggle
* Triggered when the list is toggled in mobile
* @event onClick
* Triggered when an item is clicked
* @memberof InpageNavigation
*/
supportedEvents = ['onToggle', 'onClick'];
constructor(
element,
{
stickySelector = '[data-ecl-inpage-navigation]',
containerSelector = '[data-ecl-inpage-navigation-container]',
inPageList = '[data-ecl-inpage-navigation-list]',
spySelector = '[data-ecl-inpage-navigation-link]',
toggleSelector = '[data-ecl-inpage-navigation-trigger]',
linksSelector = '[data-ecl-inpage-navigation-link]',
spyActiveContainer = 'ecl-inpage-navigation--visible',
spyOffset = 20,
spyClass = 'ecl-inpage-navigation__item--active',
spyTrigger = '[data-ecl-inpage-navigation-trigger-current]',
attachClickListener = true,
contentClass = 'ecl-inpage-navigation__heading--active',
} = {},
) {
// Check element
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
throw new TypeError(
'DOM element should be given to initialize this widget.',
);
}
this.element = element;
this.eventManager = new EventManager();
this.attachClickListener = attachClickListener;
this.stickySelector = stickySelector;
this.containerSelector = containerSelector;
this.toggleSelector = toggleSelector;
this.linksSelector = linksSelector;
this.inPageList = inPageList;
this.spyActiveContainer = spyActiveContainer;
this.spySelector = spySelector;
this.spyOffset = spyOffset;
this.spyClass = spyClass;
this.spyTrigger = spyTrigger;
this.contentClass = contentClass;
this.gumshoe = null;
this.observer = null;
this.stickyObserver = null;
this.isExpanded = false;
// Bind `this` for use in callbacks
this.handleClickOnToggler = this.handleClickOnToggler.bind(this);
this.handleClickOnLink = this.handleClickOnLink.bind(this);
this.initScrollSpy = this.initScrollSpy.bind(this);
this.initObserver = this.initObserver.bind(this);
this.handleEsc = this.handleEsc.bind(this);
this.handleShiftTab = this.handleShiftTab.bind(this);
this.activateScrollSpy = this.activateScrollSpy.bind(this);
this.deactivateScrollSpy = this.deactivateScrollSpy.bind(this);
this.destroySticky = this.destroySticky.bind(this);
this.destroyScrollSpy = this.destroyScrollSpy.bind(this);
this.destroyObserver = this.destroyObserver.bind(this);
}
// ACTIONS
/**
* Initiate sticky behaviors.
*/
initSticky() {
this.stickyInstance = new Stickyfill.Sticky(this.element);
}
/**
* Destroy sticky behaviors.
*/
destroySticky() {
if (this.stickyInstance) {
this.stickyInstance.remove();
}
}
/**
* Initiate scroll spy behaviors.
*/
initScrollSpy() {
this.gumshoe = new Gumshoe(this.spySelector, {
navClass: this.spyClass,
contentClass: this.contentClass,
offset: this.spyOffset,
reflow: true,
});
document.addEventListener('gumshoeActivate', this.activateScrollSpy, false);
document.addEventListener(
'gumshoeDeactivate',
this.deactivateScrollSpy,
false,
);
if ('IntersectionObserver' in window) {
const navigationContainer = queryOne(this.containerSelector);
if (navigationContainer) {
let previousY = 0;
let previousRatio = 0;
let initialized = false;
this.stickyObserver = new IntersectionObserver(
(entries) => {
if (entries && entries[0]) {
const entry = entries[0];
const currentY = entry.boundingClientRect.y;
const currentRatio = entry.intersectionRatio;
const { isIntersecting } = entry;
if (!initialized) {
initialized = true;
previousY = currentY;
previousRatio = currentRatio;
return;
}
if (currentY < previousY) {
if (!(currentRatio > previousRatio && isIntersecting)) {
// Scrolling down leave
this.element.classList.remove(this.spyActiveContainer);
}
} else if (currentY > previousY && isIntersecting) {
if (currentRatio > previousRatio) {
// Scrolling up enter
this.element.classList.add(this.spyActiveContainer);
}
}
previousY = currentY;
previousRatio = currentRatio;
}
},
{ root: null },
);
// observing a target element
this.stickyObserver.observe(navigationContainer);
}
}
}
/**
* Activate scroll spy behaviors.
*
* @param {Event} event
*/
activateScrollSpy(event) {
const navigationTitle = queryOne(this.spyTrigger);
this.element.classList.add(this.spyActiveContainer);
navigationTitle.textContent = event.detail.content.textContent;
}
/**
* Deactivate scroll spy behaviors.
*/
deactivateScrollSpy() {
const navigationTitle = queryOne(this.spyTrigger);
this.element.classList.remove(this.spyActiveContainer);
navigationTitle.innerHTML = '';
}
/**
* Destroy scroll spy behaviors.
*/
destroyScrollSpy() {
if (this.stickyObserver) {
this.stickyObserver.disconnect();
}
document.removeEventListener(
'gumshoeActivate',
this.activateScrollSpy,
false,
);
document.removeEventListener(
'gumshoeDeactivate',
this.deactivateScrollSpy,
false,
);
this.gumshoe.destroy();
}
/**
* Initiate observer.
*/
initObserver() {
if ('MutationObserver' in window) {
const self = this;
this.observer = new MutationObserver((mutationsList) => {
const body = queryOne('.ecl-col-l-9');
const currentInpage = queryOne('[data-ecl-inpage-navigation-list]');
mutationsList.forEach((mutation) => {
// Exclude the changes we perform.
if (
mutation &&
mutation.target &&
mutation.target.classList &&
!mutation.target.classList.contains(
'ecl-inpage-navigation__trigger-current',
)
) {
// Added nodes.
if (mutation.addedNodes.length > 0) {
[].slice.call(mutation.addedNodes).forEach((addedNode) => {
if (addedNode.tagName === 'H2' && addedNode.id) {
const H2s = queryAll('h2[id]', body);
const addedNodeIndex = H2s.findIndex(
(H2) => H2.id === addedNode.id,
);
const element =
currentInpage.childNodes[addedNodeIndex - 1].cloneNode(
true,
);
element.childNodes[0].textContent = addedNode.textContent;
element.childNodes[0].href = `#${addedNode.id}`;
currentInpage.childNodes[addedNodeIndex - 1].after(element);
}
});
}
// Removed nodes.
if (mutation.removedNodes.length > 0) {
[].slice.call(mutation.removedNodes).forEach((removedNode) => {
if (removedNode.tagName === 'H2' && removedNode.id) {
currentInpage.childNodes.forEach((item) => {
if (
item.childNodes[0].href.indexOf(removedNode.id) !== -1
) {
// Remove the element from the inpage.
item.remove();
}
});
}
});
}
self.update();
}
});
});
this.observer.observe(document, {
subtree: true,
childList: true,
});
}
}
/**
* Destroy observer.
*/
destroyObserver() {
if (this.observer) {
this.observer.disconnect();
}
}
/**
* Initialise component.
*/
init() {
if (!ECL) {
throw new TypeError('Called init but ECL is not present');
}
ECL.components = ECL.components || new Map();
const toggleElement = queryOne(this.toggleSelector, this.element);
const navLinks = queryAll(this.linksSelector, this.element);
this.initSticky(this.element);
this.initScrollSpy();
this.initObserver();
if (this.attachClickListener && toggleElement) {
toggleElement.addEventListener('click', this.handleClickOnToggler);
}
if (this.attachClickListener && navLinks) {
navLinks.forEach((link) =>
link.addEventListener('click', this.handleClickOnLink),
);
this.element.addEventListener('keydown', this.handleShiftTab);
toggleElement.addEventListener('click', this.handleClickOnToggler);
}
document.addEventListener('keydown', this.handleEsc);
// Set ecl initialized attribute
this.element.setAttribute('data-ecl-auto-initialized', 'true');
ECL.components.set(this.element, this);
}
/**
* Register a callback function for a specific event.
*
* @param {string} eventName - The name of the event to listen for.
* @param {Function} callback - The callback function to be invoked when the event occurs.
* @returns {void}
* @memberof InpageNavigation
* @instance
*
* @example
* // Registering a callback for the 'onToggle' event
* inpage.on('onToggle', (event) => {
* console.log('Toggle event occurred!', event);
* });
*/
on(eventName, callback) {
this.eventManager.on(eventName, callback);
}
/**
* Trigger a component event.
*
* @param {string} eventName - The name of the event to trigger.
* @param {any} eventData - Data associated with the event.
* @memberof InpageNavigation
*/
trigger(eventName, eventData) {
this.eventManager.trigger(eventName, eventData);
}
/**
* Update scroll spy instance.
*/
update() {
this.gumshoe.setup();
}
/**
* Invoke event listeners on toggle click.
*
* @param {Event} e
*/
handleClickOnToggler(e) {
const currentList = queryOne(this.inPageList, this.element);
const togglerElement = queryOne(this.toggleSelector, this.element);
e.preventDefault();
// Get current status
this.isExpanded = togglerElement.getAttribute('aria-expanded') === 'true';
// Toggle the expandable/collapsible
togglerElement.setAttribute(
'aria-expanded',
this.isExpanded ? 'false' : 'true',
);
if (this.isExpanded) {
currentList.classList.remove('ecl-inpage-navigation__list--visible');
} else {
currentList.classList.add('ecl-inpage-navigation__list--visible');
}
this.trigger('onToggle', { isExpanded: this.isExpanded });
}
/**
* Sets the necessary attributes to collapse inpage navigation list.
*
* @param {Event} e
*/
handleClickOnLink(e) {
const currentList = queryOne(this.inPageList, this.element);
const togglerElement = queryOne(this.toggleSelector, this.element);
const { href } = e.target;
let heading = null;
if (href) {
const id = href.split('#')[1];
if (id) {
heading = queryOne(`#${id}`, document);
}
}
currentList.classList.remove('ecl-inpage-navigation__list--visible');
togglerElement.setAttribute('aria-expanded', 'false');
const eventData = { target: heading || href, e };
this.trigger('onClick', eventData);
}
/**
* @param {Event} e
*/
handleEsc(e) {
if (e.key === 'Escape') {
this.handleClickOnLink();
}
}
/**
* @param {Event} e
*/
handleShiftTab(e) {
if (e.key === 'Tab' && e.shiftKey) {
const links = queryAll(this.linksSelector, this.element);
if (Array.isArray(links) && links.length > 0 && e.target === links[0]) {
this.handleClickOnLink();
}
}
}
/**
* Destroy component instance.
*/
destroy() {
if (this.attachClickListener && this.toggleElement) {
this.toggleElement.removeEventListener(
'click',
this.handleClickOnToggler,
);
}
if (this.attachClickListener && this.navLinks) {
this.navLinks.forEach((link) =>
link.removeEventListener('click', this.handleClickOnLink),
);
}
this.destroyScrollSpy();
this.destroySticky();
this.destroyObserver();
document.removeEventListener('keydown', this.handleEsc);
if (this.element) {
this.element.removeEventListener('keydown', this.handleShiftTab);
this.element.removeAttribute('data-ecl-auto-initialized');
ECL.components.delete(this.element);
}
}
}
export default InpageNavigation;