|
1 | | -import tabsetTemplate from './handlebars/templates/tabset.handlebars' |
2 | | - |
3 | 1 | const CONTENT_CONTAINER_ID = 'content' |
4 | 2 | const TABSET_OPEN_COMMENT = 'tabs-open' |
5 | 3 | const TABSET_CLOSE_COMMENT = 'tabs-close' |
6 | 4 | const TABPANEL_HEADING_NODENAME = 'H3' |
7 | 5 | const TABSET_CONTAINER_CLASS = 'tabset' |
8 | 6 |
|
9 | 7 | 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 = [] |
14 | 16 |
|
15 | | -function getTabSetOpeners () { |
16 | | - const tabSetOpenersIterator = document.createNodeIterator( |
| 17 | + const iterator = document.createNodeIterator( |
17 | 18 | document.getElementById(CONTENT_CONTAINER_ID), |
18 | 19 | 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 |
26 | 23 | ) |
27 | 24 |
|
28 | | - const tabSetOpeners = [] |
| 25 | + /** @type {Node} */ |
29 | 26 | 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 | + } |
74 | 49 | } |
75 | 50 | } |
76 | 51 |
|
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) |
81 | 58 |
|
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 | + }) |
84 | 95 |
|
85 | | - // Return tabset container element. |
86 | | - return container |
| 96 | + toRemove.forEach((node) => { |
| 97 | + node.parentNode.removeChild(node) |
| 98 | + }) |
87 | 99 | } |
88 | 100 |
|
89 | 101 | /** |
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} |
93 | 105 | */ |
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 |
96 | 114 | } |
97 | 115 |
|
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) |
114 | 119 | } |
115 | 120 |
|
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 | + } |
127 | 130 | } |
128 | 131 |
|
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 | +} |
141 | 139 |
|
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]') |
147 | 143 |
|
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 |
176 | 145 |
|
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) { |
185 | 146 | // 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 |
188 | 149 |
|
189 | 150 | // 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() |
193 | 154 |
|
194 | 155 | // 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 |
197 | 159 |
|
198 | 160 | // 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 |
204 | 164 | } |
0 commit comments