@financial-times/o-layout
Version:
Provides page layouts and typography as a starting point to create internal tools or products.
359 lines (327 loc) • 11.9 kB
JavaScript
import LinkedHeading from './linked-heading.js';
class Layout {
/**
* Class constructor.
*
* @param {HTMLElement} [layoutEl] - The layout element in the DOM
* @param {object} [options={}] - An options object for configuring aspects of the layout
*/
constructor(layoutEl, options) {
this.layoutEl = layoutEl;
this.highlightedHeadingIndex = 0;
const isDocsLayout = this.layoutEl.classList.contains('o-layout--docs');
const isQueryLayout = this.layoutEl.classList.contains('o-layout--query');
this.options = Object.assign({}, {
constructNav: isDocsLayout ? true : false,
navHeadingSelector: 'h1, h2, h3',
linkHeadings: true,
linkedHeadingSelector: 'h1, h2, h3, h4, h5, h6',
}, options || Layout.getDataAttributes(layoutEl));
// Get linkable headings.
const linkableHeadings = Array.from(this.layoutEl.querySelectorAll(this.options.linkedHeadingSelector))
.filter(heading => heading.getAttribute('id'));
// Construct linkable headings.
this.linkedHeadings = [];
if (this.options.linkHeadings) {
this.linkedHeadings = linkableHeadings.map(heading => new LinkedHeading(heading, {}));
}
// Get nav headings.
this.navHeadings = Array.from(this.layoutEl.querySelectorAll(this.options.navHeadingSelector))
.filter(heading => heading.getAttribute('id'));
// Construct the default navigation.
if ((isDocsLayout || isQueryLayout) && this.options.constructNav) {
this.constructNavFromDOM();
}
// Or highlight a custom navigation.
if ((isDocsLayout || isQueryLayout) && !this.options.constructNav) {
const navigation = this.layoutEl.querySelector(`.o-layout__navigation`);
if (navigation) {
/** @type {Array<HTMLAnchorElement>} */
this.navAnchors = Array.from(navigation.querySelectorAll('a'));
this.highlightNavItems();
}
}
}
/**
* Get the heading content.
*
* @param {Element} heading - the target heading
* @returns {string} - text of heading or heading's .o-layout__linked-heading__content child
* @access private
*/
static _getContentFromHeading(heading) {
const contentElement = heading.querySelector(`.o-layout__linked-heading__content`);
const headingText = contentElement ? contentElement.textContent : heading.textContent;
return headingText;
}
/**
* Construct the sidebar navigation from headings within the DOM.
*/
constructNavFromDOM () {
// Get an array of headings. If there are h2 headings followed by h3 headings (or lower),
// add a property `subItems` to the parent h2 which contains an array of the following smaller headings.
const headingsWithHierarchy = Array.from(this.navHeadings).reduce((headings, heading) => {
const supportedHeadings = ['H3', 'H4', 'H5', 'H6'];
const parents = headings.filter(heading => heading.nodeName === 'H2');
const parent = parents ? parents[parents.length - 1] : null;
if (!headings.length) {
return [heading];
}
if (parent && supportedHeadings.includes(heading.nodeName)) {
parent.subItems = parent.subItems && !parent.subItems.includes(heading) ? [...parent.subItems, heading] : [heading];
return headings;
}
headings.push(heading);
return headings;
}, []);
// Create the nav markup.
const nav = document.createElement('nav');
nav.classList.add(`o-layout__navigation`);
const list = document.createElement('ol');
list.classList.add(`o-layout__unstyled-element`);
const listInnerHTML = Array.from(headingsWithHierarchy).reduce((html, heading) => {
const pageTitleClass = heading.nodeName === 'H1' ? 'o-layout__navigation-title' : '';
return html + `
<li class="o-layout__unstyled-element ${pageTitleClass}">
<a class="o-layout__unstyled-element" href='#${heading.id}'>${Layout._getContentFromHeading(heading)}</a>
${heading.subItems ? `
<ol>
${heading.subItems.reduce((html, heading) => {
return html + `<li><a class="o-layout__unstyled-element" href="#${heading.id}">${Layout._getContentFromHeading(heading)}</a></li>`;
}, '')}
</ol>
` : ''}
</li>`;
}, '');
list.innerHTML = listInnerHTML;
nav.appendChild(list);
// Add the nav to the page.
const sidebar = this.layoutEl.querySelector(`.o-layout__sidebar`) || this.layoutEl.querySelector(`.o-layout__query-sidebar`);
if (sidebar) {
window.requestAnimationFrame(() => {
sidebar.append(nav);
});
}
/** @type {Array<HTMLAnchorElement>} */
this.navAnchors = Array.from(nav.querySelectorAll('a'));
this.highlightNavItems();
}
/**
* Unmount the sideBarNavigation.
*/
destroy() {
const constructedNav = this.layoutEl.querySelector(`.o-layout__navigation`);
if(constructedNav) {
constructedNav.remove();
}
}
/**
* Add aria-current to the navigation link that was clicked
*
* @private
* @returns {void}
*/
setupClickHandlersForNavigationSidebar() {
this.navAnchors.forEach((anchor, index) => {
anchor.addEventListener('click', () => {
for (const sidebarAnchor of this.navAnchors) {
if (sidebarAnchor === anchor) {
sidebarAnchor.setAttribute('aria-current', 'location');
this.highlightedHeadingIndex = index;
} else {
sidebarAnchor.setAttribute('aria-current', 'false');
}
}
});
});
}
/**
* Add aria-current to the correspoding link in the navigation for the header that was clicked
*
* @private
* @returns {void}
*/
setupClickHandlersForHeadings() {
this.navHeadings.forEach((headingAnchor, index) => {
headingAnchor.addEventListener('click', () => {
for (const sidebarAnchor of this.navAnchors) {
if (sidebarAnchor.hash === '#' + headingAnchor.id) {
sidebarAnchor.setAttribute('aria-current', 'location');
this.highlightedHeadingIndex = index;
} else {
sidebarAnchor.setAttribute('aria-current', 'false');
}
}
});
});
}
/**
* Add aria-current to the correspoding link in the navigation for the hash in the url if one exists
*
* @private
* @returns {void}
*/
highlightNavigationFromLocation() {
if (location.hash) {
// on page load, highlight the nav item that corresponds to the url
this.navAnchors.forEach((anchor, index) => {
const currentLocation = anchor.hash === location.hash;
const defaultLocation = location.hash === '' && index === 0;
if (currentLocation || defaultLocation) {
anchor.setAttribute('aria-current', 'location');
this.highlightedHeadingIndex = index;
} else {
anchor.setAttribute('aria-current', 'false');
}
});
}
}
/**
* Add aria-current to the correspoding link in the navigation for the header that we think
* should be highlighted based on scroll position
*
* @private
* @returns {void}
*/
setupIntersectionObserversForHeadings() {
function getY(domRect) {
return Object.prototype.hasOwnProperty.call(domRect, 'y')
? domRect.y
: domRect.top;
}
const mainSection = document.querySelector('.o-layout__main ');
let headingFontSize = '16px';
if (mainSection) {
headingFontSize = window.getComputedStyle(mainSection).fontSize;
}
const observer = new IntersectionObserver(
entries => {
let headingIndexToHighlight = this.highlightedHeadingIndex;
// Record index of which headings are above or below the intersection target
const above = [];
const below = [];
entries.forEach(entry => {
const intersectingElemIdx = this.navHeadings.findIndex(
navheading => navheading === entry.target
);
const isAbove =
getY(entry.boundingClientRect) < (getY(entry.rootBounds || {}) || 0);
if (isAbove) {
above.push(intersectingElemIdx);
} else {
below.push(intersectingElemIdx);
}
});
// If there are headings above the intersection target,
// set the active heading as the last one which is above the intersection target
if (above.length > 0) {
// Find the last heading index which is above the intersection target
headingIndexToHighlight = Math.max(...above);
} else if (below.length > 0) {
// Find the first heading index which is below the intersection target
const minIndex = Math.min(...below);
if (minIndex <= this.highlightedHeadingIndex) {
// If there are no headings above the intersection target and the current
// active heading is later down the page then use the first heading which is below
headingIndexToHighlight = minIndex - 1 >= 0 ? minIndex - 1 : 0;
}
}
this.navAnchors.forEach((anchor, index) => {
if (headingIndexToHighlight === index) {
anchor.setAttribute('aria-current', 'location');
} else {
anchor.setAttribute('aria-current', 'false');
}
});
this.highlightedHeadingIndex = headingIndexToHighlight;
}, {
rootMargin: `-${
headingFontSize
} 0px 0px 0px`,
threshold: 0.1,
}
);
this.navHeadings.forEach(heading => {
observer.observe(heading);
});
// When we reach the bottom we want to set the last heading as the current active heading
const observerbottom = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting === true) {
this.highlightedHeadingIndex = this.navAnchors.length - 1;
this.navAnchors.forEach((anchor, index) => {
if (this.highlightedHeadingIndex === index) {
anchor.setAttribute('aria-current', 'location');
} else {
anchor.setAttribute('aria-current', 'false');
}
});
}
}, {
threshold: 1, // Trigger only when whole element was visible
});
const lastElementOnPage = this.layoutEl.querySelector('.o-layout__footer') || this.layoutEl.querySelector('.o-layout__main').lastElementChild;
observerbottom.observe(lastElementOnPage);
}
/**
* Enables navigation item highlighting based on scroll position.
* Relies on heading ids and anchor href being the same.
*
* @returns {void}
*/
highlightNavItems() {
this.setupClickHandlersForNavigationSidebar();
this.setupClickHandlersForHeadings();
this.highlightNavigationFromLocation();
if (typeof self.IntersectionObserver === 'function') {
this.setupIntersectionObserversForHeadings();
}
}
/**
* Get the data attributes from the layoutEl. If the layout is being set up
* declaratively, this method is used to extract the data attributes from the DOM.
*
* @param {HTMLElement} layoutElement - The layout element in the DOM
* @returns {Object.<string, any>} - Options for configuring the layout
*/
static getDataAttributes (layoutElement) {
if (!(layoutElement instanceof HTMLElement)) {
return {};
}
return Object.keys(layoutElement.dataset).reduce((options, key) => {
// Ignore data-o-component
if (key === 'oComponent') {
return options;
}
// Build a concise key and get the option value
const shortKey = key.replace(/^oLayout(\w)(\w+)$/, (m, m1, m2) => m1.toLowerCase() + m2);
const value = layoutElement.dataset[key];
// Try parsing the value as JSON, otherwise just set it as a string
try {
options[shortKey] = JSON.parse(value.replace(/\'/g, '"'));
} catch (error) {
options[shortKey] = value;
}
return options;
}, {});
}
/**
* Initialise layout component.
*
* @param {(HTMLElement | string)} rootEl - The root element to intialise the layout in, or a CSS selector for the root element
* @param {object} [opts={}] - An options object for configuring layout behaviour.
* @returns {Layout | Layout[]} Returns either a single Layout instance or an array of Layout instances
*/
static init (rootEl, opts) {
if (!rootEl) {
rootEl = document.body;
}
if (!(rootEl instanceof HTMLElement)) {
rootEl = document.querySelector(rootEl);
}
if (rootEl instanceof HTMLElement && rootEl.matches('[data-o-component=o-layout]')) {
return new Layout(rootEl, opts);
}
return Array.from(rootEl.querySelectorAll('[data-o-component="o-layout"]'), rootEl => new Layout(rootEl, opts));
}
}
export default Layout;