header.js (6666B)
1 import { isEditableTarget } from "./dom.js"; 2 3 const headerHandlers = { 4 keydown: null, 5 afterSwap: null, 6 popstate: null, 7 linkClick: null, 8 }; 9 10 // Hosts that serve this same app, so a link to any of them stays in-page. 11 const APP_HOSTS = new Set(["prime8.dev", "www.prime8.dev"]); 12 // Pages the app renders into #content; anything else (raw files, the API, 13 // static assets) is a real load. 14 const APP_PATH = /^\/(?:(?:projects|experience)(?:\/[^/]+)?)?\/?$/; 15 16 // opensOut reports whether an anchor should open in a new tab. 17 function opensOut(a) { 18 if (a.hasAttribute("download") || a.hasAttribute("hx-get")) return false; 19 let url; 20 try { 21 url = new URL(a.href, window.location.href); 22 } catch { 23 return false; 24 } 25 if (!/^https?:$/.test(url.protocol)) return false; 26 const here = new URL(window.location.href); 27 if (url.origin === here.origin && url.pathname === here.pathname && url.hash) return false; 28 return true; 29 } 30 31 // appPath returns the in-app path an anchor points at, or "" when clicking 32 // it should be left to the browser. 33 function appPath(a) { 34 if (a.hasAttribute("download") || a.hasAttribute("hx-get")) return ""; 35 // links written into content (project READMEs and details) link out as written 36 if (a.target === "_blank" || a.closest("#project-detail, .readme-content")) return ""; 37 let url; 38 try { 39 url = new URL(a.href, window.location.href); 40 } catch { 41 return ""; 42 } 43 if (!/^https?:$/.test(url.protocol)) return ""; 44 const sameApp = 45 url.origin === window.location.origin || 46 (APP_HOSTS.has(url.hostname) && APP_HOSTS.has(window.location.hostname)); 47 if (!sameApp || !APP_PATH.test(url.pathname)) return ""; 48 return url.pathname.replace(/\/+$/, "") || "/"; 49 } 50 51 export function initHeader() { 52 const headerEl = document.getElementById("tabs"); 53 if (!headerEl) return; 54 55 const order = (headerEl.getAttribute("data-tabs") || "about").split(",").filter(Boolean); 56 const fallback = order[0] || "about"; 57 58 function goTo(tab) { 59 const link = document.querySelector(`.tab[data-tab="${tab}"]`); 60 if (!link) return; 61 62 const path = link.getAttribute("hx-get") || link.getAttribute("href"); 63 if (window.htmx && path) { 64 setActiveTabUI(tab); 65 if (window.location.pathname !== path) { 66 history.pushState({ tab }, "", path); 67 } 68 69 window.htmx.ajax("GET", path, { 70 target: "#content", 71 swap: "innerHTML", 72 }); 73 return; 74 } 75 76 link.click(); 77 } 78 79 // Every app link (relative, or absolute to prime8.dev) outside project 80 // content swaps #content instead of reloading the page. Modified 81 // clicks (new tab, new window) are left alone. 82 function navigate(path) { 83 const tab = path === "/" ? fallback : path.slice(1).split("/")[0]; 84 setActiveTabUI(tab); 85 if (window.location.pathname !== path) history.pushState({ tab }, "", path); 86 window.htmx.ajax("GET", path, { target: "#content", swap: "innerHTML" }); 87 } 88 89 if (headerHandlers.linkClick) { 90 document.removeEventListener("click", headerHandlers.linkClick); 91 } 92 headerHandlers.linkClick = (e) => { 93 if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; 94 const a = e.target.closest?.("a[href]"); 95 if (!a || !window.htmx) return; 96 const path = appPath(a); 97 if (path) { 98 e.preventDefault(); 99 navigate(path); 100 return; 101 } 102 // Everything else (other sites, raw files, links in project READMEs) 103 // opens in a new tab so the app tab stays put. In-page #anchors and 104 // non-http links (mailto:) are left to the browser. 105 if (opensOut(a)) { 106 e.preventDefault(); 107 window.open(a.href, "_blank", "noopener,noreferrer"); 108 } 109 }; 110 document.addEventListener("click", headerHandlers.linkClick); 111 112 if (headerHandlers.keydown) { 113 document.removeEventListener("keydown", headerHandlers.keydown); 114 } 115 116 headerHandlers.keydown = (e) => { 117 if (isEditableTarget(e.target)) return; 118 if (typeof window.isLightboxOpen === "function" && window.isLightboxOpen()) return; 119 120 const current = currentFromPath(order, fallback); 121 const index = order.indexOf(current); 122 123 if (e.key >= "1" && e.key <= String(order.length)) { 124 const i = Number(e.key) - 1; 125 if (order[i]) { 126 e.preventDefault(); 127 if (order[i] === current) return; 128 goTo(order[i]); 129 } 130 return; 131 } 132 133 switch (e.key) { 134 case "Tab": { 135 e.preventDefault(); 136 const next = order[(index + (e.shiftKey ? -1 : 1) + order.length) % order.length]; 137 goTo(next); 138 break; 139 } 140 case "ArrowLeft": 141 if (index > 0) { 142 e.preventDefault(); 143 goTo(order[index - 1]); 144 } 145 break; 146 case "ArrowRight": 147 if (index < order.length - 1) { 148 e.preventDefault(); 149 goTo(order[index + 1]); 150 } 151 break; 152 default: 153 break; 154 } 155 }; 156 157 document.addEventListener("keydown", headerHandlers.keydown); 158 159 if (headerHandlers.afterSwap) { 160 document.removeEventListener("htmx:afterSwap", headerHandlers.afterSwap); 161 } 162 163 headerHandlers.afterSwap = (e) => { 164 if (!e.target) return; 165 166 switch (e.target.id) { 167 case "content": 168 setActiveTabUI(currentFromPath(order, fallback)); 169 break; 170 case "project-detail": 171 updateAppTitle(); 172 break; 173 default: 174 break; 175 } 176 }; 177 178 document.addEventListener("htmx:afterSwap", headerHandlers.afterSwap); 179 180 if (headerHandlers.popstate) { 181 window.removeEventListener("popstate", headerHandlers.popstate); 182 } 183 184 headerHandlers.popstate = () => { 185 setActiveTabUI(currentFromPath(order, fallback)); 186 }; 187 188 window.addEventListener("popstate", headerHandlers.popstate); 189 190 setActiveTabUI(currentFromPath(order, fallback)); 191 } 192 193 // Same as utils.WindowTitle: "prime8:~/projects/janitor", always in full. 194 export function windowTitle(path) { 195 return `prime8:${path}`; 196 } 197 198 export function updateAppTitle() { 199 const path = window.location.pathname.replace(/\/+$/, ""); 200 document.title = windowTitle(`~${path}`); 201 } 202 203 function setActiveTabUI(tab) { 204 document.querySelectorAll(".tab").forEach((el) => { 205 const isActive = el.getAttribute("data-tab") === tab; 206 el.classList.toggle("active", isActive); 207 if (isActive) { 208 el.setAttribute("aria-current", "page"); 209 } else { 210 el.removeAttribute("aria-current"); 211 } 212 }); 213 214 updateAppTitle(); 215 } 216 217 function currentFromPath(order, fallback) { 218 const path = window.location.pathname; 219 if (path === "/" || path === "") return fallback; 220 const tab = path.replace(/^\//, "").split("/")[0]; 221 return order.includes(tab) ? tab : fallback; 222 }