Skip to content

Commit

Permalink
Add table of contents
Browse files Browse the repository at this point in the history
- Add table of contents to inform, learn, and plan pages (ohi+/conduct)
- Document front-matter properties in the README (including the new toc property)
- Remove unused ".section" style
- Rename "default-section" class to "section"
- Add option to remove cards from any list page by setting `cards: false` in the front-matter

Relates to: #106, #67, #30, #28
  • Loading branch information
robyngit committed Oct 6, 2021
1 parent 3647b31 commit 405e236
Show file tree
Hide file tree
Showing 19 changed files with 710 additions and 37 deletions.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ add-ons called "shortcodes". Content markdown files also use metadata at the sta

Front-matter is located at the top of each markdown file, between two `---` separators, in `yaml` format. The properties common to all pages are:

- title
- name
- bg_image
- `title`: The full title of the page to show in the page header.
- `name`: The short name of the page to use in menus, etc. May be the same as title.
- `bg_image`: The image to use in the page's header
- `toc`: Set to `true` to add a table of contents to the page. The page will use a 2-col layout instead of a 3-col layout. This only works for single pages (pages that aren't named `index.md` or `_index.md`)

## Shortcodes

Expand Down
10 changes: 8 additions & 2 deletions assets/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,22 @@ import params from '@params'

// Functions for the main navigation menu.
import nav from './partials/nav/nav.js'

// Data visualizations
import dataviz from './data-viz/dataviz.js'
// Table of contents
import toc from './toc.js';

// What to do when the DOM has loaded
async function callback() {

// Enable the navigation functions
nav.init();

dataviz.init();
// Render any data visualizations that are on the page
dataviz.init();

// Set up the table of contents functions, if there is a TOC.
toc.init();

};

Expand Down
330 changes: 330 additions & 0 deletions assets/js/toc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,330 @@
// parameters imported from the baseof.html template which builds the JS
import * as params from '@params'

/**
* CSS Selector strings to select HTML elements that make up the TOC
* @typedef {Object} TOCSelectors
* @property {string} mainContainer The entire block (including TOC & content)
* @property {string} toc The table of contents
* @property {string} content The contents that the TOC refers to (i.e. the text with ids)
* @property {string} tocContainer The element that doesn't get affixed but that contains
* the TOC
* @property {string} list The <ul> or <ol> elements that makes up the TOC (relative to
* the selectors.toc selector)
* @property {string} button The buttons used to open/close the TOC on small screens
* (relative to the selectors.toc selector)
* @property {string} items The <li> elements within the TOC. <li> must contain an <a>.
* (relative to the selectors.toc selector)
* @property {string} nav The nav element that surrounds the TOC (relative to the
* selectors.toc selector)
*/

/**
* Creates an interactive table of contents if the right HTML elements exist in the page.
* @param {Object} options - Configurable options for the TOC.
* @property {TOCSelectors} selectors - CSS Selector strings to select HTML elements that
* make up the TOC
* @property {Object} classes - Classes to add/remove to elements depending on their state
* @property {number} breakpoint - Screen width at which to switch from mobile style rules
* to desktop styles.
*
*/
function init({
selectors = {
mainContainer: ".toc",
toc: ".toc__toc",
content: ".toc__content",
tocContainer: ".toc__toc-container",
list: "ul, ol",
button: ".toc__button",
items: "li",
nav: "nav"
},
classes = {
affixedMobile: "toc__toc--affixed-mobile",
affixedDesktop: "toc__toc--affixed-desktop",
active: "active"
},
breakpoint = params.breakpoint,
} = {}) {

const elements = selectElements();
const tocExists = elements.button !== null &&
elements.button !== undefined &&
elements.list !== null &&
elements.list !== undefined

// Only continue if the the necessary TOC elements exist in the page
if (!tocExists) {
return
}

// Set a minimum height for the nav container to keep it from glitching on mobile
setTimeout(function () {
elements.tocContainer.style.minHeight = (elements.toc.offsetHeight + 2) + "px";
}, 500);
// Start the list as hidden (for mobile)
elements.list.setAttribute("aria-hidden", "false"); // TODO?
// Add a link to identify the toc links
setListeners();

/**
* throttle - Limits the number of times that a function can be called within a time
* period. From: https://www.afasterweb.com/2017/09/26/performance-basics-throttling/
*
* @param {function} fn The function to throttle
* @param {number} wait Minimum milliseconds to wait before allowing the callback
* function to be called again
* @return {function} Returns the callback function wrapped in the throttle function
*/
function throttle(fn, wait) {
var time = Date.now();
return function () {
if ((time + wait - Date.now()) < 0) {
fn();
time = Date.now();
}
}
}

/**
* selectElements - Select all of the elements that this script will manipulate.
*/
function selectElements() {

const elements = {}
// Select the TOC items
elements.el = document.querySelector(selectors.mainContainer);
elements.toc = document.querySelector(selectors.toc);
elements.content = document.querySelector(selectors.content);

if (elements.el) {
elements.tocContainer = elements.el.querySelector(selectors.tocContainer);
}

if (elements.toc) {

elements.list = elements.toc.querySelector(selectors.list);
elements.button = elements.toc.querySelector(selectors.button);
elements.nav = elements.toc.querySelector(selectors.nav);

elements.items = [];

elements.toc.querySelectorAll(selectors.items).forEach(function (item, index) {

var link = item.querySelector("a");
// Don't count items without links
if (link) {
const href = link.getAttribute("href");
const targetSelector = href.substring(href.lastIndexOf("#") + 1);
const id = "item__" + targetSelector;
const lastChild = item.querySelector("ol li:last-child, ul li:last-child");

item.setAttribute("id", id);

elements.items[index] = {
index: index,
el: item,
href: href,
id: id,
targetIdSelector: targetSelector,
hrefDestEl: document.getElementById(targetSelector),
lastChild: lastChild
}
}
})
}
return elements
}

/**
* setListeners - set the event listeners that will trigger scrollspy behaviour, opening
* and closing the TOC, etc.
*/
function setListeners () {

// Scroll and resize events
window.removeEventListener("scroll", throttle(classChangeEvents, 20), false);
window.addEventListener("scroll", throttle(classChangeEvents, 20), false);
window.removeEventListener("resize", throttle(classChangeEvents, 20), false);
window.addEventListener("resize", throttle(classChangeEvents, 20), false);

// If on mobile, and menu is affixed, then close menu on item click
elements.items.forEach(item => {
item.el.addEventListener('click', function () {
var winWidth = window.innerWidth || document.documentElement.clientWidth;
if (
(winWidth < breakpoint) &&
elements.toc.classList.contains(classes.affixedMobile)
) {
toggleTOC(true);
}
});
});

// Show/hide the TOC when the button is clicked (on mobile)
elements.button.addEventListener("click", function () {
if (tocExists) {
var open = JSON.parse(elements.button.getAttribute("aria-expanded"));
toggleTOC(open);
}
});
}

/**
* Event handler for scroll and & window resize. Updates the TOC.
*/
function classChangeEvents() {

// Check if menu is changing from not affixed to affixed on mobile
var isAffixedBefore = elements.toc.classList.contains(classes.affixedMobile);

// Affix TOC on scroll MOBILE
elementFromTop(
elemTrigger = [elements.el],
targetElSelector = selectors.toc,
classToAdd = classes.affixedMobile,
distanceFromTop = -(elements.toc.offsetHeight - 100),
unit = 'pixels',
offset = (elements.toc.offsetHeight - 100)
);

// If menu changed from not affixed to affixed on mobile, minimize TOC
var isAffixedAfter = elements.toc.classList.contains(classes.affixedMobile);
if (!isAffixedBefore && isAffixedAfter) {
toggleTOC(true);
}

// Affix TOC on scroll (affix soooner if on DESKTOP)
elementFromTop(
elemTrigger = [elements.el],
targetElSelector = selectors.toc,
classToAdd = classes.affixedDesktop,
distanceFromTop = 0,
unit = 'pixels',
offset = elements.toc.offsetHeight
);

// Scrollspy - activate menu link when corresponding section comes into view.
elements.items.forEach(function (item, index) {
// Get the position of the next element to calculate the offset
var nextItem = elements.items[index + 1];
var offset;

// Keep parent active when child elements are active
if (item.lastChild && item.lastChild != null) {
var lastChildObj = elements.items.find(function (searchItem) {
// Sometimes there are empty items!
if (searchItem) {
return searchItem.id == item.lastChild.id
}
}),
nextItem = elements.items[lastChildObj.index + 1];
}
// Otherwise, keep active until the next item becomes active
if (nextItem) {
offset = (nextItem.hrefDestEl.getBoundingClientRect().top - item.hrefDestEl.getBoundingClientRect().top) - item.hrefDestEl.offsetHeight;
// Otherwise, stay active until the end of the entire block
} else {
offset = (elements.el.getBoundingClientRect().bottom - item.hrefDestEl.getBoundingClientRect().top);
}

elementFromTop(
elemTrigger = [item.hrefDestEl],
targetElSelector = "#" + item.id,
classToAdd = classes.active,
distanceFromTop = 159,
unit = 'pixels',
offset = ((offset - 160) * (-1))
);

});

// If on desktop, make sure that all of TOC is shown. Also make sure it's shown when
// it's not affixed on Mobile
var winWidth = window.innerWidth || document.documentElement.clientWidth;
if (winWidth >= breakpoint || (!elements.toc.classList.contains(classes.affixedMobile))) {
toggleTOC(false);
}

}

/**
* toggleTOC - Sets or unsets the aria-expanded and aria-hidden attributes on the
* TOCmenu and tocMenu button.
* @param {boolean} open - indicates whether the menu should be closed (true) or open
* (false).
*/
function toggleTOC (open) {

try {
if (tocExists) {
elements.button.setAttribute("aria-expanded", !open);
elements.list.setAttribute("aria-hidden", open);
}
} catch (e) {
console.log("Error toggling the TOC");
console.log(e);
}
};

/**
* elementFromTop - Adds or removes a class to an element based on its position in the
* view window.
*
* @param {HTMLElement} elemTrigger The HTML element which will trigger
* adding/removing a class when in view.
* @param {string} targetElSelector The class used to identify the target
* element(s)
* @param {string} classToAdd The class to add or remove to the target
* element(s)
* @param {number} distanceFromTop How far past the top of the view port should
* the top of element be before the class is added? (in pixels or % of element height)
* @param {string} unit ('percent' or 'pixels')
* @param {number} offset If set to 0, the class will be removed once the
* users scrolls past the bottom of the element. If a + number, the class will be
* removed sooner. If a - number, the class will be removed later.
*/
function elementFromTop(elemTrigger, targetElSelector, classToAdd, distanceFromTop, unit, offset) {

var winY = window.innerHeight || document.documentElement.clientHeight,
elTriggerLength = elemTrigger.length,
elTargetLength, distTop, distPercent, distPixels, distUnit, elTarget, i, j;

for (i = 0; i < elTriggerLength; ++i) {

elTarget = document.querySelectorAll(targetElSelector);
elTargetLength = elTarget.length;
distTop = elemTrigger[i].getBoundingClientRect().top;
distBottom = elemTrigger[i].getBoundingClientRect().bottom;
distPercent = Math.round((distTop / winY) * 100);
distPixels = Math.round(distTop);
distUnit = unit == 'percent' ? distPercent : distPixels;

if (!offset) {
var offset = 0;
}
// If trigger in view, then
if (distUnit <= distanceFromTop && (distBottom - offset) >= 0) {
// For each of the targets
for (j = 0; j < elTargetLength; ++j) {
// If the doesn't already contain the active class
if (!elTarget[j].classList.contains(classToAdd)) {
// Add it
elTarget[j].classList.add(classToAdd);
}
}
// If not in view
} else {
// Then remove the classes
elemTrigger[i].classList.remove(classToAdd);
for (j = 0; j < elTargetLength; ++j) {
elTarget[j].classList.remove(classToAdd);
}
}
}
}

}

export default { init }
14 changes: 2 additions & 12 deletions assets/scss/_common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,8 @@ a h4{
}
}

.section {
padding-top: 90px;
padding-bottom: 90px;

&-sm {
padding-top: 40px;
padding-bottom: 40px;
}

&-title {
margin-bottom: 30px;
}
.section-title {
margin-bottom: 30px;
}

.bg-cover {
Expand Down
Loading

0 comments on commit 405e236

Please sign in to comment.