diff --git a/assets/js/handlebars/templates/tabset.handlebars b/assets/js/handlebars/templates/tabset.handlebars deleted file mode 100644 index 43c783ac8..000000000 --- a/assets/js/handlebars/templates/tabset.handlebars +++ /dev/null @@ -1,20 +0,0 @@ -
- {{#each tabs}} - - {{/each}} -
- -{{#each tabs}} -
- {{#each content}} - {{{this}}} - {{/each}} -
-{{/each}} diff --git a/assets/js/tabsets.js b/assets/js/tabsets.js index 251ed2023..fdbd22a36 100644 --- a/assets/js/tabsets.js +++ b/assets/js/tabsets.js @@ -1,5 +1,3 @@ -import tabsetTemplate from './handlebars/templates/tabset.handlebars' - const CONTENT_CONTAINER_ID = 'content' const TABSET_OPEN_COMMENT = 'tabs-open' const TABSET_CLOSE_COMMENT = 'tabs-close' @@ -7,198 +5,160 @@ 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} 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 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 }