/*! Strand UI Vanilla Runtime | MIT License | dillingerstaffing.com */ // // Pair this script with on every HTML page // that uses Strand components without a framework (Preact/Vue/Svelte). // The runtime enhances static HTML with interactive behaviors that // framework components handle internally. // // Currently registered: // - CodeBlock: copy-to-clipboard button on .strand-code-block__pre // - Nav: hamburger toggle on .strand-nav__hamburger // - Nav: glass nav :has() fallback (body.strand-glass-nav-active) // // Nav, mobileMenu: the framework packages take a `mobileMenu` prop that // defaults to true and, when false, omits the hamburger and its panel (DL // 19.1.1: an application shell with a persistent destination region must not // also carry a hamburger). For a CSS-only consumer the markup IS the API, so // the equivalent is simply not writing .strand-nav__hamburger and // .strand-nav__mobile-menu. attachNav returns early when either is absent, so // omitting them is supported rather than merely tolerated, and there is no // class to remember. // - Banner: :has() fallback (body.strand-banner-active) // - Tabs: click-to-switch + keyboard nav on [role="tablist"] // - StarRating: click / keyboard / hover on [data-strand-component="star-rating"] // // CSP: no inline code, no eval, no external imports. (() => { // ════════════════════════════════════════════════════════════════════ // CodeBlock: copy-to-clipboard // ════════════════════════════════════════════════════════════════════ const SELECTOR_PRE = ".strand-code-block__pre"; const WRAPPER_CLASS = "strand-code-block"; const BUTTON_CLASS = "strand-code-block__copy"; const COPIED_CLASS = "strand-code-block__copy--copied"; const WRAPPER_ATTR = "data-strand-copy"; const LABEL_DEFAULT = "Copy code to clipboard"; const LABEL_COPIED = "Copied"; const SUCCESS_DURATION_MS = 1500; function buildIcon(variant: string, pathD: string): SVGSVGElement { const svgNS = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(svgNS, "svg"); svg.setAttribute( "class", `strand-code-block__copy-icon strand-code-block__copy-icon--${variant}`, ); svg.setAttribute("viewBox", "0 0 16 16"); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "1.75"); svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); svg.setAttribute("aria-hidden", "true"); svg.setAttribute("focusable", "false"); const path = document.createElementNS(svgNS, "path"); path.setAttribute("d", pathD); svg.appendChild(path); return svg; } function buildClipboardIcon(): SVGSVGElement { return buildIcon( "clipboard", "M5 2.5h4.5L12 5v8.5a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5z M9 2.5V5h3", ); } function buildCheckIcon(): SVGSVGElement { return buildIcon("check", "M3.5 8.5l3 3 6-6.5"); } function buildCopyButton(): HTMLButtonElement { const btn = document.createElement("button"); btn.type = "button"; btn.className = BUTTON_CLASS; btn.setAttribute("aria-label", LABEL_DEFAULT); btn.appendChild(buildClipboardIcon()); btn.appendChild(buildCheckIcon()); return btn; } async function copyText(text: string): Promise { if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text); return true; } catch { // Fall through to legacy path. } } try { const ta = document.createElement("textarea"); ta.value = text; ta.setAttribute("readonly", ""); ta.style.position = "absolute"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.select(); const ok = document.execCommand("copy"); document.body.removeChild(ta); return ok; } catch { return false; } } function attachCopyButton(pre: Element): void { if (!pre || pre.nodeType !== 1) return; let wrapper = pre.parentElement; if (!wrapper || !wrapper.classList.contains(WRAPPER_CLASS)) { const newWrapper = document.createElement("div"); newWrapper.className = WRAPPER_CLASS; pre.parentNode?.insertBefore(newWrapper, pre); newWrapper.appendChild(pre); wrapper = newWrapper; } if (wrapper.querySelector(`.${BUTTON_CLASS}`)) return; const btn = buildCopyButton(); let resetTimer = 0; btn.addEventListener("click", async () => { const code = pre.textContent || ""; const ok = await copyText(code); if (!ok) return; btn.classList.add(COPIED_CLASS); btn.setAttribute("aria-label", LABEL_COPIED); if (resetTimer) { clearTimeout(resetTimer); } resetTimer = window.setTimeout(() => { btn.classList.remove(COPIED_CLASS); btn.setAttribute("aria-label", LABEL_DEFAULT); resetTimer = 0; }, SUCCESS_DURATION_MS); }); wrapper.appendChild(btn); wrapper.setAttribute(WRAPPER_ATTR, ""); } // ════════════════════════════════════════════════════════════════════ // Nav: mobile menu toggle // ════════════════════════════════════════════════════════════════════ const NAV_WIRED_ATTR = "data-strand-nav-wired"; const NAV_OPEN_CLASS = "strand-nav__mobile-menu--open"; function attachNav(navEl: Element): void { if (!navEl || navEl.nodeType !== 1) return; const btn = navEl.querySelector(".strand-nav__hamburger") as HTMLElement | null; const menu = navEl.querySelector(".strand-nav__mobile-menu") as HTMLElement | null; if (!btn || !menu) return; if (btn.getAttribute(NAV_WIRED_ATTR) === "true") return; btn.setAttribute(NAV_WIRED_ATTR, "true"); function closeMenu(): void { menu!.classList.remove(NAV_OPEN_CLASS); btn!.setAttribute("aria-expanded", "false"); } btn.addEventListener("click", () => { const open = menu!.classList.toggle(NAV_OPEN_CLASS); btn!.setAttribute("aria-expanded", String(open)); }); for (const a of menu.querySelectorAll("a")) { a.addEventListener("click", closeMenu); } document.addEventListener("keydown", (e: KeyboardEvent) => { if (e.key === "Escape" && menu!.classList.contains(NAV_OPEN_CLASS)) { closeMenu(); btn!.focus(); } }); } // ════════════════════════════════════════════════════════════════════ // Nav + Banner: :has() cross-browser fallback // ════════════════════════════════════════════════════════════════════ const HAS_SUPPORT = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("selector(:has(*))"); function attachHasFallback(): void { if (HAS_SUPPORT) return; if (document.querySelector(".strand-nav--glass")) { document.body.classList.add("strand-glass-nav-active"); } if (document.querySelector(".strand-banner")) { document.body.classList.add("strand-banner-active"); } } // ════════════════════════════════════════════════════════════════════ // Tabs: vanilla click-to-switch + keyboard navigation // ════════════════════════════════════════════════════════════════════ const TABS_WIRED_ATTR = "data-strand-tabs-wired"; function attachTabs(tabsEl: Element): void { const tablist = tabsEl.querySelector("[role='tablist']") as HTMLElement | null; if (!tablist) return; if (tablist.getAttribute(TABS_WIRED_ATTR) === "true") return; tablist.setAttribute(TABS_WIRED_ATTR, "true"); const tabs = Array.from(tablist.querySelectorAll("[role='tab']")) as HTMLElement[]; if (tabs.length === 0) return; function activateTab(tab: HTMLElement): void { const panelId = tab.getAttribute("aria-controls"); for (const t of tabs) { const isActive = t === tab; t.setAttribute("aria-selected", String(isActive)); t.tabIndex = isActive ? 0 : -1; const pId = t.getAttribute("aria-controls"); if (pId) { const panel = tabsEl.querySelector(`#${pId}`) as HTMLElement | null; if (panel) panel.hidden = !isActive; } } if (panelId) { const activePanel = tabsEl.querySelector(`#${panelId}`) as HTMLElement | null; if (activePanel) activePanel.hidden = false; } } for (const tab of tabs) { tab.addEventListener("click", () => activateTab(tab)); } tablist.addEventListener("keydown", (e: KeyboardEvent) => { const current = tabs.findIndex((t) => t.getAttribute("aria-selected") === "true"); let next: number | null = null; switch (e.key) { case "ArrowRight": next = (current + 1) % tabs.length; break; case "ArrowLeft": next = (current - 1 + tabs.length) % tabs.length; break; case "Home": next = 0; break; case "End": next = tabs.length - 1; break; default: return; } e.preventDefault(); activateTab(tabs[next]); tabs[next].focus(); }); } // ════════════════════════════════════════════════════════════════════ // StarRating: click / keyboard / hover // ════════════════════════════════════════════════════════════════════ const STAR_RATING_SELECTOR = "[data-strand-component=\"star-rating\"]"; const STAR_SELECTOR = ".strand-star-rating__star"; const STAR_ACTIVE_CLASS = "strand-star-rating__star--active"; const STAR_RATING_WIRED_ATTR = "data-strand-star-wired"; function paintStars(group: HTMLElement, display: number): void { const stars = group.querySelectorAll(STAR_SELECTOR); for (const star of stars) { const v = Number(star.getAttribute("data-star-value") || "0"); if (v <= display && v > 0) { star.classList.add(STAR_ACTIVE_CLASS); } else { star.classList.remove(STAR_ACTIVE_CLASS); } } } function syncAriaChecked(group: HTMLElement, value: number): void { const stars = group.querySelectorAll(STAR_SELECTOR); for (const star of stars) { const v = Number(star.getAttribute("data-star-value") || "0"); star.setAttribute("aria-checked", v === value ? "true" : "false"); } } function attachStarRating(group: Element): void { if (!group || group.nodeType !== 1) return; const groupEl = group as HTMLElement; if (groupEl.getAttribute(STAR_RATING_WIRED_ATTR) === "true") return; groupEl.setAttribute(STAR_RATING_WIRED_ATTR, "true"); const readOnly = groupEl.classList.contains("strand-star-rating--readonly"); if (readOnly) return; const getValue = (): number => Number(groupEl.getAttribute("data-value") || "0"); const stars = groupEl.querySelectorAll(STAR_SELECTOR); for (const star of stars) { const v = Number(star.getAttribute("data-star-value") || "0"); star.addEventListener("click", (e) => { e.preventDefault(); groupEl.setAttribute("data-value", String(v)); paintStars(groupEl, v); syncAriaChecked(groupEl, v); groupEl.dispatchEvent( new CustomEvent("strand:star-rating-change", { bubbles: true, detail: { value: v }, }), ); }); star.addEventListener("keydown", (e: Event) => { const key = (e as KeyboardEvent).key; if (key === " " || key === "Enter") { e.preventDefault(); (star as HTMLButtonElement).click(); } }); star.addEventListener("mouseenter", () => paintStars(groupEl, v)); star.addEventListener("mouseleave", () => paintStars(groupEl, getValue())); star.addEventListener("focus", () => paintStars(groupEl, v)); star.addEventListener("blur", () => paintStars(groupEl, getValue())); } // Initial paint reflects the authored data-value paintStars(groupEl, getValue()); syncAriaChecked(groupEl, getValue()); } // ════════════════════════════════════════════════════════════════════ // Public entry point // ════════════════════════════════════════════════════════════════════ function attachAll(root?: Element | Document | null): void { const scope = root || document; // CodeBlock copy buttons const pres = scope.querySelectorAll(SELECTOR_PRE); for (const pre of pres) { attachCopyButton(pre); } // Nav mobile menus const navs = scope.querySelectorAll("nav.strand-nav"); for (const nav of navs) { attachNav(nav); } // :has() fallback (only runs once, on document scope) if (!root || root === document) { attachHasFallback(); } // Tabs vanilla enhancement const tabContainers = scope.querySelectorAll(".strand-tabs"); for (const tc of tabContainers) { attachTabs(tc); } // StarRating vanilla enhancement const starGroups = scope.querySelectorAll(STAR_RATING_SELECTOR); for (const g of starGroups) { attachStarRating(g); } } // Auto-init on DOMContentLoaded or immediately if already parsed. if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => attachAll()); } else { attachAll(); } // Expose for dynamic re-scans and backward compatibility. (window as any).strandUI = { attach: attachAll }; (window as any).strandCopyButtons = { attach: attachAll }; })();