Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 0 additions & 20 deletions assets/js/handlebars/templates/tabset.handlebars

This file was deleted.

290 changes: 125 additions & 165 deletions assets/js/tabsets.js
Original file line number Diff line number Diff line change
@@ -1,204 +1,164 @@
import tabsetTemplate from './handlebars/templates/tabset.handlebars'

const CONTENT_CONTAINER_ID = 'content'
const TABSET_OPEN_COMMENT = 'tabs-open'
const TABSET_CLOSE_COMMENT = 'tabs-close'
const TABPANEL_HEADING_NODENAME = 'H3'
const TABSET_CONTAINER_CLASS = 'tabset'

export function initialize () {
const tabSetOpeners = getTabSetOpeners()
const tabsetContainers = tabSetOpeners.map(processTabset)
tabsetContainers.forEach(set => activateTabset(set))
}
// Done in read and mutate parts to avoid layout thrashing.
// Reading inner text requires layout so we want to read
// all headings before we start mutating the DOM.

/** @type {[Node, [string, HTMLElement[]][]][]} */
const sets = []
/** @type {Node[]} */
const toRemove = []

function getTabSetOpeners () {
const tabSetOpenersIterator = document.createNodeIterator(
const iterator = document.createNodeIterator(
document.getElementById(CONTENT_CONTAINER_ID),
NodeFilter.SHOW_COMMENT,
{
acceptNode (node) {
return node.nodeValue.trim() === TABSET_OPEN_COMMENT
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT
}
}
(node) => node.nodeValue.trim() === TABSET_OPEN_COMMENT
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT
)

const tabSetOpeners = []
/** @type {Node} */
let opener
while ((opener = tabSetOpenersIterator.nextNode())) {
tabSetOpeners.push(opener)
}

return tabSetOpeners
}

/**
* Mapped to an array of tabset opener comment markers, processes a tabset.
* Prepares data held in HTML for the template, wraps the tabset elements,
* and applies the template.
*
* @param {Element} element A tabset opener comment node.
* @param {Integer} tabSetIndex
* @param {Array} array
* @returns {Array} Tabset container element.
*/
function processTabset (element, tabSetIndex, _array) {
const allSetNodes = []
const tabSet = []
const tabPanel = {
label: '',
content: []
}

while ((element = element.nextSibling)) {
if (isTabSetCloser(element)) {
// Next node is closer comment; push current panel data and break.
pushPanel(tabPanel, tabSet, tabSetIndex)
break
}

// Push node to array of all tabset nodes, which are to be wrapped.
allSetNodes.push(element)

if (element.nodeName === TABPANEL_HEADING_NODENAME) {
// Next node is tab heading; push current panel data, set next tab panel
// heading text and reset next tab panel content array.
pushPanel(tabPanel, tabSet, tabSetIndex)
tabPanel.label = element.innerText
tabPanel.content = []
} else {
// Next node is some other node; push to current tab panel content array.
tabPanel.content.push(element.outerHTML)
while ((opener = iterator.nextNode())) {
const set = []
sets.push([opener, set])

/** @type {HTMLElement[]} */
let tabContent

let node = opener
while ((node = node.nextSibling)) {
if (node.nodeName === TABPANEL_HEADING_NODENAME) {
// Tab heading.
tabContent = []
set.push([node.innerText, tabContent])
toRemove.push(node)
} else if (node.nodeName === '#comment' && node.nodeValue.trim() === TABSET_CLOSE_COMMENT) {
// Closer comment.
toRemove.push(node)
break
} else if (tabContent) {
// Tab content.
tabContent.push(node)
}
}
}

// Wrap all tabset nodes in new container element.
const container = document.createElement('div')
container.className = TABSET_CONTAINER_CLASS
wrapElements(allSetNodes, container)
// Now we can mutate DOM.
sets.forEach(([opener, set], setIndex) => {
const tabset = el('div', {
class: TABSET_CONTAINER_CLASS
})
opener.parentNode.replaceChild(tabset, opener)

// Apply template to tabset container element.
container.innerHTML = tabsetTemplate({tabs: tabSet})
const tablist = el('div', {
role: 'tablist',
class: 'tabset-tablist'
})
tabset.appendChild(tablist)

set.forEach(([text, content], index) => {
const selected = index === 0
const tabId = `tab-${setIndex}-${index}`
const tabPanelId = `tabpanel-${setIndex}-${index}`

const tab = el('button', {
role: 'tab',
id: tabId,
class: 'tabset-tab',
tabindex: selected ? 0 : -1,
'aria-selected': selected,
'aria-controls': tabPanelId
})
tab.innerText = text
tab.addEventListener('click', handleTabClick)
tab.addEventListener('keydown', handleTabKeydown)
tablist.appendChild(tab)

const tabPanel = el('div', {
role: 'tabpanel',
id: tabPanelId,
class: 'tabset-panel',
hidden: !selected ? '' : undefined,
tabindex: selected ? 0 : -1,
'aria-labelledby': tabId
})
tabPanel.replaceChildren(...content)
tabset.appendChild(tabPanel)
})
})

// Return tabset container element.
return container
toRemove.forEach((node) => {
node.parentNode.removeChild(node)
})
}

/**
* Determines whether or not a DOM node is treated as a tabset closer marker.
* @param {Node} node A DOM node.
* @returns {Boolean}
* @param {string} tagName
* @param {Record<string, any>} attributes
* @returns {HTMLElement}
*/
function isTabSetCloser (node) {
return node.nodeName === '#comment' && node.nodeValue.trim() === TABSET_CLOSE_COMMENT
function el (tagName, attributes) {
const element = document.createElement(tagName)
for (const key in attributes) {
if (attributes[key] != null) {
element.setAttribute(key, attributes[key])
}
}
return element
}

/**
* Pushes panel data object to tabset.
* The setIndex is used to provide each tabset’s elements unique IDs.
*
* @param {Object} panel The panel object to push.
* @param {Array} set The parent tabset.
* @param {Integer} setIndex The parent tabset index.
*/
function pushPanel (panel, set, setIndex) {
// If panel data is incomplete, do not push. (Usually the case when the first
// tab panel heading is encountered.)
if (panel.label === '' && !panel.content.length) return false

const label = panel.label
const content = panel.content
set.push({label, content, setIndex})
/** @param {MouseEvent} event */
function handleTabClick (event) {
activateTab(event.currentTarget)
}

/**
* Wraps elements with the wrapper element.
*
* @param {Array} elements The elements to wrap.
* @param {Element} wrapper The wrapping element.
*/
function wrapElements (elements, wrapper) {
if (!elements || !elements.length) return false

elements[0].parentNode.insertBefore(wrapper, elements[0])
elements.forEach((el) => wrapper.appendChild(el))
/** @param {KeyboardEvent} event */
function handleTabKeydown (event) {
if (keys[event.code]) {
event.preventDefault()
const tabs = [...event.currentTarget.parentNode.childNodes]
const currentIndex = tabs.indexOf(event.currentTarget)
const newIndex = keys[event.code](currentIndex, tabs.length)
activateTab(tabs.at(newIndex % tabs.length))
}
}

/**
* Adds behaviour to a processed tabset.
*
* @param {Element} set A processed tabset container element.
*/
function activateTabset (set) {
// Register tab buttons and panels, and create initial state object.
const state = {
tabs: set.querySelectorAll(':scope [role="tab"]'),
panels: set.querySelectorAll(':scope [role="tabpanel"]'),
activeIndex: 0
}
/** @type {Dictionary<string, (index: number, length: number) => number>} */
const keys = {
ArrowLeft: (index) => index - 1,
ArrowRight: (index) => index + 1,
Home: () => 0,
End: (index, length) => length - 1
}

state.tabs.forEach((tab, index) => {
// Pointing/touch device.
tab.addEventListener('click', (_event) => {
setActiveTab(index, state)
})
/** @param {HTMLButtonElement} tab */
function activateTab (tab) {
const prev = tab.parentNode.querySelector('[aria-selected=true]')

// Keyboard (arrow keys should wrap from first to last and vice-versa).
tab.addEventListener('keydown', (event) => {
const lastIndex = state.tabs.length - 1

if (event.code === 'ArrowLeft') {
event.preventDefault()
if (state.activeIndex === 0) {
setActiveTab(lastIndex, state)
} else {
setActiveTab(state.activeIndex - 1, state)
}
} else if (event.code === 'ArrowRight') {
event.preventDefault()
if (state.activeIndex === lastIndex) {
setActiveTab(0, state)
} else {
setActiveTab(state.activeIndex + 1, state)
}
} else if (event.code === 'Home') {
event.preventDefault()
setActiveTab(0, state)
} else if (event.code === 'End') {
event.preventDefault()
setActiveTab(lastIndex, state)
}
})
})
}
if (prev === tab) return

/**
* Updates active tab button and panel. Covers ARIA attributes and
* (de)activates tab navigation.
*
* @param {Integer} index
* @param {Object} state
*/
function setActiveTab (index, state) {
// Set previously active tab button as inactive.
state.tabs[state.activeIndex].setAttribute('aria-selected', 'false')
state.tabs[state.activeIndex].tabIndex = -1
prev.setAttribute('aria-selected', 'false')
prev.tabIndex = -1

// Set newly active tab button as active.
state.tabs[index].setAttribute('aria-selected', 'true')
state.tabs[index].tabIndex = 0
state.tabs[index].focus()
tab.setAttribute('aria-selected', 'true')
tab.tabIndex = 0
tab.focus()

// Set previously active tab panel as inactive.
state.panels[state.activeIndex].setAttribute('hidden', '')
state.panels[state.activeIndex].tabIndex = -1
const prevPanel = document.getElementById(prev.getAttribute('aria-controls'))
prevPanel.setAttribute('hidden', '')
prevPanel.tabIndex = -1

// Set newly active tab panel as active.
state.panels[index].removeAttribute('hidden')
state.panels[index].tabIndex = 0

// Update state's active index.
state.activeIndex = index
const panel = document.getElementById(tab.getAttribute('aria-controls'))
panel.removeAttribute('hidden')
panel.tabIndex = 0
}
Loading