define([
"jquery",
"underscore",
"backbone",
"text!templates/tableOfContentsLi.html",
"text!templates/tableOfContentsUL.html",
"text!templates/tableOfContents.html",
], function ($, _, Backbone, TOCTemplateLi, TOCTemplateUl, TOCTemplate) {
/**
* @class TOCView
* @classdesc The Table of Contents View is a vertical navigation menu that links to other
sections within the same view.
The TOC can have 2 levels of content. The top level is referred to as 'topLevelItem'.
Second level items are referred to as 'h2' (they come from 'h2' tags). H1s get passed
in when the TOC view is instantiated (see `PortalSectionView.js` for an example). If
there are 'h2' tags within the 'topLevelItem' containers, these will be listed under
the 'topLevelItem'.
* @classcategory Views
* @extends Backbone.View
*/
var TOCView = Backbone.View.extend(
/** @lends TOCView.prototype */ {
tagName: "div",
className: "toc toc-view",
type: "TOC",
/* Renders the compiled template into HTML */
templateUL: _.template(TOCTemplateUl),
templateLI: _.template(TOCTemplateLi),
mainTemplate: _.template(TOCTemplate),
templateInvisibleH1: _.template(
'<h1 id="<%=linkDisplay%>" style="display: inline"></h1>',
),
events: {
"click .dropdown": "toggleDropdown",
},
// The element on the page that contains the content that this table of contents
// is associated with.
contentEl: null,
/*
* A list of custom items to insert into the TOC
* {
"text": "Portal Description",
"icon": "icon-file-text-alt",
"link": $(".header"),
"showSubItems": false
}
*/
topLevelItems: {},
//If set to true, will render one level of sub items/links
showSubItems: true,
/* Construct a new instance */
initialize: function (options) {
if (typeof options !== "undefined") {
this.topLevelItems = options.topLevelItems || "";
this.contentEl = options.contentEl || "";
this.className = options.className || "toc toc-view";
this.addScrollspy = options.addScrollspy || true;
this.affix = options.affix || true;
if (options.showSubItems === false) {
this.showSubItems = false;
}
}
},
/* Render the view */
render: function () {
var liTemplate = this.templateLI,
h1Template = this.templateInvisibleH1;
this.$el.html(
this.mainTemplate({
ulTemplate: this.templateUL(),
}),
);
// Save references to where we should insert the links
this.desktopUl = this.$el.find(".desktop ul");
this.mobile = this.$el.find(".mobile");
// Save references to the toggle links (and divider) so we can update their text/display on spyScoll
this.topLevelMobileToggle = this.mobile.find(
".top-level-items .dropdown-toggle",
);
this.secondLevelMobileToggle = this.mobile.find(
".second-level-items .dropdown-toggle",
);
this.mobileDivider = this.mobile.find(".mobile-toc-divider");
// Render the top level items that have been passed in
_.each(
this.topLevelItems,
function (topLevelItem) {
// Create a link to display based on the text of the TOC item
topLevelItem.linkDisplay = topLevelItem.text
.replace(/[\W_]+/g, "-")
.toLowerCase()
.replace(/^[\W_]+/g, "");
// Make an invisible (empty) H1 tag and stick it into the el
// that's the target of the TOC
$(topLevelItem.link).prepend(
h1Template({ linkDisplay: topLevelItem.linkDisplay }),
);
// Render the top level item
this.desktopUl.append(liTemplate(topLevelItem));
if (
typeof topLevelItem.showSubItems == "undefined" ||
topLevelItem.showSubItems == true
) {
_.each(
this.createLinksFromHeaders(topLevelItem.link),
function (link, index) {
this.appendLink(link, index);
},
this,
);
}
},
this,
);
// If no custom top-level items were given, find the headers in the content
if (!this.topLevelItems.length && this.contentEl) {
//Create links from the headers found in the content
_.each(
this.createLinksFromHeaders(),
function (link, index) {
this.appendLink(link, index);
},
this,
);
}
return this;
},
/**
* appendLink - Adds the generated link to both the desktop and mobile TOCs
*
* @param {HTMLLIElement} link The top-level item to add, including second-level UL if present
* @param {number} index The index of the top-level item
*/
appendLink: function (link, index) {
// Append to the main (desktop) navigation
this.desktopUl.append(link);
// Append to the mobile navigation
var mobileLink = $(link.clone()),
submenu = $(mobileLink.find("ul").clone());
// Make a list of only top-level li's
mobileLink.find("ul").remove();
mobileLink.data("index", index);
this.mobile.find(".top-level-items ul").append(mobileLink);
// If there's a submenu, add it to the second-level-items menu.
// Add ID that allows us to match the parent to the submenu.
if (submenu && submenu.length) {
submenu.addClass("submenu hidden");
submenu.data("index", index);
submenu.children("li").data("index", index);
this.mobile
.find(".second-level-items .dropdown-menu")
.append(submenu);
}
},
createLinksFromHeaders: function (contentEl, headerLevel) {
//If no content element is specified, use the one attached to this view
if (!contentEl && this.contentEl) {
var contentEl = this.contentEl;
}
//If there is no content element attached to the view either, then exit
else if (!contentEl && !this.contentEl) {
return [];
}
//If there is no header level specified, find them in the content
if (!headerLevel) {
var headerLevel;
//Use the first-level header if it exists
if ($(contentEl).find("h1").length) {
headerLevel = "h1";
}
//Otherwise find second-level headers
else if ($(contentEl).find("h2").length) {
headerLevel = "h2";
}
//Otherwise find third-level headers
else if ($(contentEl).find("h3").length) {
headerLevel = "h3";
}
//Exit this function if there are no headers
else {
return [];
}
}
//Create an array to contain all the link elements
var linkItems = [];
// Within each top level item, look for header tags and
// render them as second level TOC items
var headers = $(contentEl).find(headerLevel);
_.each(
headers,
function (header) {
//Create the link HTML
var linkItem = $(
this.templateLI({
link: "#" + $(header).attr("id"),
text: $(header).text(),
LIclass: "top-level-item",
}),
);
linkItem.addClass("top-level-item");
//If we want to show subitems in the table of contents, find them
if (this.showSubItems) {
var nextEl = $(header).next(),
subHeaderLevel = "H" + (parseInt(headerLevel.charAt(1)) + 1),
subItems = $(this.templateUL());
while (nextEl.length) {
if (nextEl[0].tagName == subHeaderLevel) {
subItems.append(
this.templateLI({
link: "#" + nextEl.attr("id"),
text: nextEl.text(),
LIclass: "second-level-item",
}),
);
} else if (nextEl[0].tagName == headerLevel.toUpperCase()) {
break;
}
nextEl = nextEl.next();
}
//If at least one subheader/subitem was found, add them to the top-level item
if (subItems.children().length) {
linkItem.append(subItems);
}
}
//Create the link item and add to the array
linkItems.push(linkItem);
},
this,
);
return linkItems;
},
/**
* addScrollspy - Adds and refreshes bootstrap's scrollSpy functionality,
* and sets the listener to call this view's scrollSpyExtras when
* Bootstrap's "activate" event is called. This function should be called
* anytime the DOM is updated.
*/
renderScrollspy: function () {
try {
var view = this;
var scrollSpyClass = "scrollspy-TOC-" + this.cid;
var scrollSpyTarget = "." + scrollSpyClass;
this.$el.addClass(scrollSpyClass);
// Manually set scrollspy data,
// see https://github.com/twbs/bootstrap/issues/20022#issuecomment-561376832
var $spy = $("body").scrollspy({
target: scrollSpyTarget,
offset: 35,
});
var newSpyData = $spy.data();
newSpyData.scrollspy.selector = scrollSpyTarget + " .nav li > a";
$.fn.scrollspy.call($spy, newSpyData);
$spy.scrollspy("process");
$spy.scrollspy("refresh");
// Remove any active classes to start
var activeEls = this.$(scrollSpyTarget + " .active");
activeEls.removeClass("active");
// Add scroll spy
$("body").off("activate");
$("body").on("activate", function (e) {
view.scrollSpyExtras(e);
});
$(window).off("resize");
$(window).on("resize", function () {
$spy.scrollspy("refresh");
});
} catch (e) {
console.log("Error adding scrollspy! Error message: " + e);
}
},
/**
* Adds and refreshes bootstrap's affix functionality. This function
* should be called after the DOM has been rendered or updated. Renamed
* from postRender to avoid it being called automatically by Backbone.
* @since 2.27.0
*/
setAffix: function () {
try {
var isVisible = this.$el.find(":visible").length > 0;
if (!isVisible || !this.$el.offset()) {
return;
}
if (this.affix === true) {
this.$el.affix({ offset: this.$el.offset().top });
}
if (this.addScrollspy) {
this.renderScrollspy();
}
} catch (e) {
console.log(
"Error affixing the table of contents, error message: " + e,
);
}
},
/**
* scrollSpyExtras - Adds extra functionality to Bootstrap's scrollSpy function.
* This function is called anytime the "activate" event is called by bootstrap.
* For the desktop TOC, if activates the parent LI in the case that a second-level
* LI is active. For the mobile TOC, it changes text displayed in this.topLevelMobileToggle
* and this.secondLevelMobileToggle to the active top-level and second-level item, respectively.
* It also makes only the active second-level menu visible under the secondLevelMobile dropdown.
*
* @param {event} e The "activate" event triggered when an LI element is activated by bootstrap's ScrollSpy
*/
scrollSpyExtras: function (e) {
try {
if (e && e.target) {
// console.log($(e.target)[0].innerText);
var activeLI = $(e.target),
mobileContainer = activeLI.closest(".mobile"),
isTopLevel = activeLI.hasClass("top-level-item"),
isMobile = mobileContainer && mobileContainer.length;
// --- DESKTOP --- //
// For the desktop nav, just highlight the parent item if the
// activated item is a second-level item.
if (!isMobile) {
if (!isTopLevel) {
activeLI.closest(".top-level-item").addClass("active");
}
return;
}
// --- MOBILE --- //
var allSubmenus = mobileContainer.find(".submenu"),
allToplevelLIs = mobileContainer.find(".top-level-item"),
itemText = activeLI.find("a").text().trim(),
index = activeLI.data("index"); // Used to match submenus to parent LIs.
if (isTopLevel) {
// Update the toggle text, hide submenu displays
this.topLevelMobileToggle.text(itemText);
this.secondLevelMobileToggle.text("");
this.secondLevelMobileToggle
.closest(".second-level-items")
.addClass("hidden");
this.mobileDivider.addClass("hidden");
// Get the corresponding child submenu that should be active
var activeSubMenu = _.filter(allSubmenus, function (submenu) {
return $(submenu).data("index") == index;
});
} else {
// Get the parent LI, make it active, and update the toggle text
activeTopLI = _.filter(allToplevelLIs, function (topLI) {
return $(topLI).data("index") == index;
});
$(activeTopLI).addClass("active");
this.topLevelMobileToggle.text(
$(activeTopLI).children("a").text().trim(),
);
this.secondLevelMobileToggle.text(itemText);
// Find the menu this LI is within
var activeSubMenu = activeLI.closest("ul");
}
// Hide all submenus so only max 1 is active at a time
allSubmenus.addClass("hidden");
// Ensure the active submenu & associated toggle text is shown
if (activeSubMenu && activeSubMenu.length) {
$(activeSubMenu).removeClass("hidden");
this.secondLevelMobileToggle
.closest(".second-level-items")
.removeClass("hidden");
this.mobileDivider.removeClass("hidden");
if (this.secondLevelMobileToggle.text() == "") {
this.secondLevelMobileToggle.text("Sub-sections");
}
}
}
} catch (error) {
console.log(
"error adding extra scrollSpy functionality to portal section, error message: " +
error,
);
}
},
/**
* toggleDropdown - Extends bootstrap's dropdown menu functionality by
* hiding the dropdown menu when the user clicks the dropdown toggle or
* any of the options within the dropdown menu.
*
* @param {event} e The click event on any part of the dropdown element
*/
toggleDropdown: function (e) {
try {
if (
e &&
e.target &&
$(e.target).closest(".dropdown").children(".dropdown-menu")
) {
// The entire dropdown element including toggle and menu
var $dropdown = $(e.target).closest(".dropdown"),
// The menu that we wish to show and hide on click
$menu = $dropdown.children(".dropdown-menu");
// Wait for bootstrap to add or remove the open class on $dropdown
setTimeout(function () {
if ($menu.hasClass("hidden") || $dropdown.hasClass("open")) {
$menu.removeClass("hidden");
} else {
$menu.addClass("hidden");
}
}, 5);
}
} catch (error) {
console.log(
"error hiding TOC dropdown menu on click, error message: " + error,
);
}
},
/**
* onClose - Close and destroy the view
*/
onClose: function () {
// Make sure to stop scrollSpy listeners
$("body").off("activate");
$(window).off("resize");
},
},
);
return TOCView;
});