Skip to content

Commit da64416

Browse files
Improve tabset init (#1999)
1 parent 8d57a5b commit da64416

File tree

2 files changed

+125
-185
lines changed

2 files changed

+125
-185
lines changed

assets/js/handlebars/templates/tabset.handlebars

Lines changed: 0 additions & 20 deletions
This file was deleted.

assets/js/tabsets.js

Lines changed: 125 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -1,204 +1,164 @@
1-
import tabsetTemplate from './handlebars/templates/tabset.handlebars'
2-
31
const CONTENT_CONTAINER_ID = 'content'
42
const TABSET_OPEN_COMMENT = 'tabs-open'
53
const TABSET_CLOSE_COMMENT = 'tabs-close'
64
const TABPANEL_HEADING_NODENAME = 'H3'
75
const TABSET_CONTAINER_CLASS = 'tabset'
86

97
export function initialize () {
10-
const tabSetOpeners = getTabSetOpeners()
11-
const tabsetContainers = tabSetOpeners.map(processTabset)
12-
tabsetContainers.forEach(set => activateTabset(set))
13-
}
8+
// Done in read and mutate parts to avoid layout thrashing.
9+
// Reading inner text requires layout so we want to read
10+
// all headings before we start mutating the DOM.
11+
12+
/** @type {[Node, [string, HTMLElement[]][]][]} */
13+
const sets = []
14+
/** @type {Node[]} */
15+
const toRemove = []
1416

15-
function getTabSetOpeners () {
16-
const tabSetOpenersIterator = document.createNodeIterator(
17+
const iterator = document.createNodeIterator(
1718
document.getElementById(CONTENT_CONTAINER_ID),
1819
NodeFilter.SHOW_COMMENT,
19-
{
20-
acceptNode (node) {
21-
return node.nodeValue.trim() === TABSET_OPEN_COMMENT
22-
? NodeFilter.FILTER_ACCEPT
23-
: NodeFilter.FILTER_REJECT
24-
}
25-
}
20+
(node) => node.nodeValue.trim() === TABSET_OPEN_COMMENT
21+
? NodeFilter.FILTER_ACCEPT
22+
: NodeFilter.FILTER_REJECT
2623
)
2724

28-
const tabSetOpeners = []
25+
/** @type {Node} */
2926
let opener
30-
while ((opener = tabSetOpenersIterator.nextNode())) {
31-
tabSetOpeners.push(opener)
32-
}
33-
34-
return tabSetOpeners
35-
}
36-
37-
/**
38-
* Mapped to an array of tabset opener comment markers, processes a tabset.
39-
* Prepares data held in HTML for the template, wraps the tabset elements,
40-
* and applies the template.
41-
*
42-
* @param {Element} element A tabset opener comment node.
43-
* @param {Integer} tabSetIndex
44-
* @param {Array} array
45-
* @returns {Array} Tabset container element.
46-
*/
47-
function processTabset (element, tabSetIndex, _array) {
48-
const allSetNodes = []
49-
const tabSet = []
50-
const tabPanel = {
51-
label: '',
52-
content: []
53-
}
54-
55-
while ((element = element.nextSibling)) {
56-
if (isTabSetCloser(element)) {
57-
// Next node is closer comment; push current panel data and break.
58-
pushPanel(tabPanel, tabSet, tabSetIndex)
59-
break
60-
}
61-
62-
// Push node to array of all tabset nodes, which are to be wrapped.
63-
allSetNodes.push(element)
64-
65-
if (element.nodeName === TABPANEL_HEADING_NODENAME) {
66-
// Next node is tab heading; push current panel data, set next tab panel
67-
// heading text and reset next tab panel content array.
68-
pushPanel(tabPanel, tabSet, tabSetIndex)
69-
tabPanel.label = element.innerText
70-
tabPanel.content = []
71-
} else {
72-
// Next node is some other node; push to current tab panel content array.
73-
tabPanel.content.push(element.outerHTML)
27+
while ((opener = iterator.nextNode())) {
28+
const set = []
29+
sets.push([opener, set])
30+
31+
/** @type {HTMLElement[]} */
32+
let tabContent
33+
34+
let node = opener
35+
while ((node = node.nextSibling)) {
36+
if (node.nodeName === TABPANEL_HEADING_NODENAME) {
37+
// Tab heading.
38+
tabContent = []
39+
set.push([node.innerText, tabContent])
40+
toRemove.push(node)
41+
} else if (node.nodeName === '#comment' && node.nodeValue.trim() === TABSET_CLOSE_COMMENT) {
42+
// Closer comment.
43+
toRemove.push(node)
44+
break
45+
} else if (tabContent) {
46+
// Tab content.
47+
tabContent.push(node)
48+
}
7449
}
7550
}
7651

77-
// Wrap all tabset nodes in new container element.
78-
const container = document.createElement('div')
79-
container.className = TABSET_CONTAINER_CLASS
80-
wrapElements(allSetNodes, container)
52+
// Now we can mutate DOM.
53+
sets.forEach(([opener, set], setIndex) => {
54+
const tabset = el('div', {
55+
class: TABSET_CONTAINER_CLASS
56+
})
57+
opener.parentNode.replaceChild(tabset, opener)
8158

82-
// Apply template to tabset container element.
83-
container.innerHTML = tabsetTemplate({tabs: tabSet})
59+
const tablist = el('div', {
60+
role: 'tablist',
61+
class: 'tabset-tablist'
62+
})
63+
tabset.appendChild(tablist)
64+
65+
set.forEach(([text, content], index) => {
66+
const selected = index === 0
67+
const tabId = `tab-${setIndex}-${index}`
68+
const tabPanelId = `tabpanel-${setIndex}-${index}`
69+
70+
const tab = el('button', {
71+
role: 'tab',
72+
id: tabId,
73+
class: 'tabset-tab',
74+
tabindex: selected ? 0 : -1,
75+
'aria-selected': selected,
76+
'aria-controls': tabPanelId
77+
})
78+
tab.innerText = text
79+
tab.addEventListener('click', handleTabClick)
80+
tab.addEventListener('keydown', handleTabKeydown)
81+
tablist.appendChild(tab)
82+
83+
const tabPanel = el('div', {
84+
role: 'tabpanel',
85+
id: tabPanelId,
86+
class: 'tabset-panel',
87+
hidden: !selected ? '' : undefined,
88+
tabindex: selected ? 0 : -1,
89+
'aria-labelledby': tabId
90+
})
91+
tabPanel.replaceChildren(...content)
92+
tabset.appendChild(tabPanel)
93+
})
94+
})
8495

85-
// Return tabset container element.
86-
return container
96+
toRemove.forEach((node) => {
97+
node.parentNode.removeChild(node)
98+
})
8799
}
88100

89101
/**
90-
* Determines whether or not a DOM node is treated as a tabset closer marker.
91-
* @param {Node} node A DOM node.
92-
* @returns {Boolean}
102+
* @param {string} tagName
103+
* @param {Record<string, any>} attributes
104+
* @returns {HTMLElement}
93105
*/
94-
function isTabSetCloser (node) {
95-
return node.nodeName === '#comment' && node.nodeValue.trim() === TABSET_CLOSE_COMMENT
106+
function el (tagName, attributes) {
107+
const element = document.createElement(tagName)
108+
for (const key in attributes) {
109+
if (attributes[key] != null) {
110+
element.setAttribute(key, attributes[key])
111+
}
112+
}
113+
return element
96114
}
97115

98-
/**
99-
* Pushes panel data object to tabset.
100-
* The setIndex is used to provide each tabset’s elements unique IDs.
101-
*
102-
* @param {Object} panel The panel object to push.
103-
* @param {Array} set The parent tabset.
104-
* @param {Integer} setIndex The parent tabset index.
105-
*/
106-
function pushPanel (panel, set, setIndex) {
107-
// If panel data is incomplete, do not push. (Usually the case when the first
108-
// tab panel heading is encountered.)
109-
if (panel.label === '' && !panel.content.length) return false
110-
111-
const label = panel.label
112-
const content = panel.content
113-
set.push({label, content, setIndex})
116+
/** @param {MouseEvent} event */
117+
function handleTabClick (event) {
118+
activateTab(event.currentTarget)
114119
}
115120

116-
/**
117-
* Wraps elements with the wrapper element.
118-
*
119-
* @param {Array} elements The elements to wrap.
120-
* @param {Element} wrapper The wrapping element.
121-
*/
122-
function wrapElements (elements, wrapper) {
123-
if (!elements || !elements.length) return false
124-
125-
elements[0].parentNode.insertBefore(wrapper, elements[0])
126-
elements.forEach((el) => wrapper.appendChild(el))
121+
/** @param {KeyboardEvent} event */
122+
function handleTabKeydown (event) {
123+
if (keys[event.code]) {
124+
event.preventDefault()
125+
const tabs = [...event.currentTarget.parentNode.childNodes]
126+
const currentIndex = tabs.indexOf(event.currentTarget)
127+
const newIndex = keys[event.code](currentIndex, tabs.length)
128+
activateTab(tabs.at(newIndex % tabs.length))
129+
}
127130
}
128131

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

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

148-
// Keyboard (arrow keys should wrap from first to last and vice-versa).
149-
tab.addEventListener('keydown', (event) => {
150-
const lastIndex = state.tabs.length - 1
151-
152-
if (event.code === 'ArrowLeft') {
153-
event.preventDefault()
154-
if (state.activeIndex === 0) {
155-
setActiveTab(lastIndex, state)
156-
} else {
157-
setActiveTab(state.activeIndex - 1, state)
158-
}
159-
} else if (event.code === 'ArrowRight') {
160-
event.preventDefault()
161-
if (state.activeIndex === lastIndex) {
162-
setActiveTab(0, state)
163-
} else {
164-
setActiveTab(state.activeIndex + 1, state)
165-
}
166-
} else if (event.code === 'Home') {
167-
event.preventDefault()
168-
setActiveTab(0, state)
169-
} else if (event.code === 'End') {
170-
event.preventDefault()
171-
setActiveTab(lastIndex, state)
172-
}
173-
})
174-
})
175-
}
144+
if (prev === tab) return
176145

177-
/**
178-
* Updates active tab button and panel. Covers ARIA attributes and
179-
* (de)activates tab navigation.
180-
*
181-
* @param {Integer} index
182-
* @param {Object} state
183-
*/
184-
function setActiveTab (index, state) {
185146
// Set previously active tab button as inactive.
186-
state.tabs[state.activeIndex].setAttribute('aria-selected', 'false')
187-
state.tabs[state.activeIndex].tabIndex = -1
147+
prev.setAttribute('aria-selected', 'false')
148+
prev.tabIndex = -1
188149

189150
// Set newly active tab button as active.
190-
state.tabs[index].setAttribute('aria-selected', 'true')
191-
state.tabs[index].tabIndex = 0
192-
state.tabs[index].focus()
151+
tab.setAttribute('aria-selected', 'true')
152+
tab.tabIndex = 0
153+
tab.focus()
193154

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

198160
// Set newly active tab panel as active.
199-
state.panels[index].removeAttribute('hidden')
200-
state.panels[index].tabIndex = 0
201-
202-
// Update state's active index.
203-
state.activeIndex = index
161+
const panel = document.getElementById(tab.getAttribute('aria-controls'))
162+
panel.removeAttribute('hidden')
163+
panel.tabIndex = 0
204164
}

0 commit comments

Comments
 (0)