From a4fc62ca5cc5c5f53694f2c4a6d831d84df6f42a Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 18 Mar 2026 16:32:42 -0400 Subject: [PATCH] [tabs] Fix conflict with anchor links / hashes --- src/pg/patterns/core/tabs/_tabs.js | 194 +++++++++++++++++------------ 1 file changed, 114 insertions(+), 80 deletions(-) diff --git a/src/pg/patterns/core/tabs/_tabs.js b/src/pg/patterns/core/tabs/_tabs.js index d83eda3..13d4f60 100644 --- a/src/pg/patterns/core/tabs/_tabs.js +++ b/src/pg/patterns/core/tabs/_tabs.js @@ -1,6 +1,6 @@ -/* DS2 core (c) 2024 Alexander McIlwraith - Released under Creative Commons Attribution-ShareAlike 4.0 International - */ +/* DS2 core (c) 2024–2026 Alexander McIlwraith + Released under Creative Commons Attribution-ShareAlike 4.0 International +*/ // create a pure JS mouse click event const click = new MouseEvent('click', { @@ -9,48 +9,56 @@ const click = new MouseEvent('click', { cancelable: true }); +// wait for an element to appear in the DOM const waitForElement = (selector) => { - return new Promise(resolve => { - if (document.querySelector(selector)) { - return resolve(document.querySelector(selector)); - } + return new Promise(resolve => { + if (document.querySelector(selector)) { + return resolve(document.querySelector(selector)); + } + const observer = new MutationObserver(() => { + if (document.querySelector(selector)) { + observer.disconnect(); + resolve(document.querySelector(selector)); + } + }); + observer.observe(document.body, { + childList: true, + subtree: true + }); + }); +}; - const observer = new MutationObserver(mutations => { - if (document.querySelector(selector)) { - observer.disconnect(); - resolve(document.querySelector(selector)); - } - }); - - // If you get "parameter 1 is not of type 'Node'" error, see https://stackoverflow.com/a/77855838/492336 - observer.observe(document.body, { - childList: true, - subtree: true - }); - }); -} +// Tab logic +const tabNamespace = "#tab:"; const chooseTab = (tab) => { const siblings = Array.from(tab.parentNode.children); siblings.forEach(sibling => sibling.classList.remove("selected")); tab.classList.add("selected"); - - const tabPanels = Array.from(tab.parentNode.parentNode.children) + + const tabPanels = Array.from(tab.parentNode.parentNode.children); tabPanels.forEach(panel => panel.classList.remove("open")); - const tabPanelId = tab.getAttribute("id").replace("tab", "tab-panel"); - document.getElementById(tabPanelId).classList.add("open"); -} + const tabPanelId = tab + .getAttribute("id") + .replace("tab", "tab-panel"); -let pushState = 0; + document + .getElementById(tabPanelId) + .classList.add("open"); +}; + +let pushStateCount = 0; let tabsetCount = 0; -export function init(container = document, spacer = true, args = {}) { +export function init(container = document, spacer = true) { + container.querySelectorAll(".tab-group, tabset").forEach(tabGroup => { - if (tabGroup.querySelector("[role=tablist]") === null) { + + if (tabGroup.querySelector('[role="tablist"]') === null) { + if (tabGroup.getAttribute("id") == null) { - tabGroup.setAttribute("id", "tab-group-" + tabsetCount); - tabsetCount++; + tabGroup.setAttribute("id", `tab-group-${tabsetCount++}`); } const tabgroup = tabGroup.getAttribute("id"); @@ -58,24 +66,22 @@ export function init(container = document, spacer = true, args = {}) { Array.from(tabGroup.children).forEach(child => { - // is details? - let dtls = child.nodeName == "DETAILS" ? true : false; + const isDetails = child.nodeName === "DETAILS"; + const tabLabel = + isDetails + ? child.querySelector("summary")?.innerHTML + : child.getAttribute("tab") || child.getAttribute("data-tab"); - // get the tab text - let tab = dtls ? child.querySelector("summary").innerHTML : child.getAttribute("tab") || child.getAttribute("data-tab"); + if (tabLabel !== null) { - // if the tab text is not blank - if (tab !== null) { - const tabID = tab.replace(/\W+/g, "-").toLowerCase(); - + const tabID = tabLabel.replace(/\W+/g, "-").toLowerCase(); + let tabPanel; - // define the tab panel content - let tabPanel = null; - if (dtls) { + if (isDetails) { tabPanel = child; tabPanel.setAttribute("open", ""); } else { - tabPanel = document.createElement('div'); + tabPanel = document.createElement("div"); tabPanel.appendChild(child.cloneNode(true)); } @@ -83,76 +89,104 @@ export function init(container = document, spacer = true, args = {}) { tabPanel.className = tablist === "" ? "open" : ""; tabPanel.setAttribute("role", "tabpanel"); tabPanel.setAttribute("tabindex", "0"); - tabPanel.setAttribute("aria-labelledby", `tab-${tabgroup}-${tabID}`); + tabPanel.setAttribute( + "aria-labelledby", + `tab-${tabgroup}-${tabID}` + ); + child.parentNode.replaceChild(tabPanel, child); - tablist += ``; + + tablist += ` + + `; } else { child.classList.add("tab-hidden"); } }); - const ul = document.createElement('ul'); + const ul = document.createElement("ul"); ul.setAttribute("role", "tablist"); tabGroup.insertBefore(ul, tabGroup.firstChild); - ul.innerHTML = spacer != true ? `${tablist}` : `${tablist}`; - if ( tabGroup.hasAttribute("order") || tabGroup.hasAttribute("data-order") ) { - let order = (tabGroup.getAttribute("order") || tabGroup.getAttribute("data-order")).split(","); + ul.innerHTML = spacer !== true + ? tablist + : `${tablist}`; + + // Tab ordering + if (tabGroup.hasAttribute("order") || tabGroup.hasAttribute("data-order")) { + + const order = ( + tabGroup.getAttribute("order") || + tabGroup.getAttribute("data-order") + ).split(","); const items = Array.from(ul.getElementsByTagName("li")); + items.sort((a, b) => { const aa = order.indexOf(a.textContent.trim()); const bb = order.indexOf(b.textContent.trim()); - - // Check if both items exist in orderArray - if (aa === -1) return 1; // Move to the end if not found - if (bb === -1) return -1; // Move to the end if not found - - return aa - bb; // Sort based on the defined order + if (aa === -1) return 1; + if (bb === -1) return -1; + return aa - bb; }); - ul.innerHTML = ''; + ul.innerHTML = ""; items.forEach(item => ul.appendChild(item)); chooseTab(items[0]); } + // Tab event handlers tabGroup.querySelectorAll('[role="tab"]').forEach(tab => { + tab.addEventListener("click", (evt) => { - if (pushState == 0) { - window.history.pushState({rand: Math.random(), pushState: pushState, tab: tab.parentNode.firstChild.getAttribute("id")}, "", `#${tab.parentNode.firstChild.getAttribute("id")}`); - pushState++; - } + + const tabId = tab.getAttribute("id"); + const hash = `${tabNamespace}${tabId}`; chooseTab(evt.currentTarget); - window.history.pushState({rand: Math.random(), pushState: pushState, tab: tab.getAttribute("id")}, "", `#${tab.getAttribute("id")}`); - pushState++; + + window.history.pushState( + { tab: tabId }, + "", + hash + ); + + pushStateCount++; }); tab.addEventListener("keypress", (e) => { - e.preventDefault(); - if( e.which == 32 || e.which == 13 ) { + if (e.which === 32 || e.which === 13) { + e.preventDefault(); e.currentTarget.dispatchEvent(click); } - }) + }); }); - ul.querySelector("li").classList.add("selected"); - } - if (document.location.hash != "" && document.location.hash.substring(0,5) == "#tab-") { - waitForElement(document.location.hash).then((el) => { - //el.scrollIntoView(); - el.focus(); - el.dispatchEvent(click); - }); + ul.querySelector("li")?.classList.add("selected"); } }); - window.addEventListener("popstate", function (e) { - e.preventDefault(); - if (e.state != null) { - chooseTab(document.querySelector(`#${e.state.tab}`)); - } else { - history.go(-1); + // Initial hash handling (tabs only) + if (location.hash.startsWith(tabNamespace)) { + const tabId = location.hash.replace(tabNamespace, ""); + waitForElement(`#${tabId}`).then(el => { + el.focus(); + el.dispatchEvent(click); + }); + } + + // History navigation (tabs only) + window.addEventListener("popstate", (e) => { + if (!location.hash.startsWith(tabNamespace)) return; + if (e.state?.tab) { + const tab = document.querySelector(`#${e.state.tab}`); + if (tab) chooseTab(tab); } }); -} +} \ No newline at end of file