experience.js (6095B)
1 import { isEditableTarget } from "./dom.js"; 2 3 const experienceHandlers = { 4 keydown: null, 5 popstate: null, 6 }; 7 8 // One job is lit at a time: its timeline lane, rail and card together. 9 // The selected job (url, click/tap, arrow keys) is lit unless a mouse is 10 // over, or focus is on, another job, which lights while it stays there. 11 // Moving across the gaps between jobs keeps the last one lit, so nothing 12 // blinks; leaving the list goes back to the selection. 13 export function initExperience() { 14 const section = document.getElementById("experience"); 15 if (!section) return; 16 17 const rows = Array.from(section.querySelectorAll(".exp-row")); 18 const lanes = Array.from(section.querySelectorAll(".exp-lane")); 19 const scroller = section.querySelector(".experience-detail"); 20 const chart = section.querySelector(".exp-chart"); 21 if (rows.length === 0 || !scroller) return; 22 23 let selectedIndex = 0; 24 let previewIndex = -1; 25 let litIndex = -1; 26 27 function paint() { 28 const lit = previewIndex >= 0 ? previewIndex : selectedIndex; 29 if (lit !== litIndex) { 30 litIndex = lit; 31 rows.forEach((row, i) => row.classList.toggle("active", i === lit)); 32 lanes.forEach((lane, i) => lane.classList.toggle("active", i === lit)); 33 } 34 lanes.forEach((lane, i) => 35 lane.setAttribute("aria-current", String(i === selectedIndex)), 36 ); 37 } 38 39 function indexOf(target) { 40 const el = target?.closest?.(".exp-row, .exp-lane"); 41 if (!el || !section.contains(el)) return -1; 42 return Number(el.dataset.index); 43 } 44 45 function indexFromPath() { 46 const id = decodeURIComponent( 47 window.location.pathname.match(/^\/experience\/([^/]+)$/)?.[1] || "", 48 ); 49 if (!id) return -1; 50 return rows.findIndex((row) => row.dataset.id === id); 51 } 52 53 function updateExperienceURL(index) { 54 const id = rows[index]?.dataset.id; 55 if (!id) return; 56 57 const path = `/experience/${encodeURIComponent(id)}`; 58 if (window.location.pathname !== path) { 59 history.replaceState({ tab: "experience" }, "", path); 60 } 61 } 62 63 function scrollSelectionIntoView(index) { 64 const target = rows[index]; 65 if (!target) return; 66 67 // the sticky timeline strip covers the top of the list 68 const inset = 69 chart && getComputedStyle(chart).position === "sticky" 70 ? chart.offsetHeight 71 : 0; 72 const scrollerRect = scroller.getBoundingClientRect(); 73 const targetRect = target.getBoundingClientRect(); 74 const top = scrollerRect.top + inset; 75 const isAbove = targetRect.top < top + 12; 76 const isBelow = targetRect.bottom > scrollerRect.bottom - 12; 77 if (!isAbove && !isBelow) return; 78 79 const targetTop = targetRect.top - top + scroller.scrollTop - 8; 80 scroller.scrollTo({ top: Math.max(targetTop, 0), behavior: "smooth" }); 81 } 82 83 function select(index, { updateURL = true, scroll = false } = {}) { 84 if (index < 0 || index >= rows.length) return; 85 selectedIndex = index; 86 paint(); 87 if (scroll) scrollSelectionIntoView(index); 88 if (updateURL) updateExperienceURL(index); 89 } 90 91 function preview(index) { 92 if (index === previewIndex) return; 93 previewIndex = index; 94 paint(); 95 } 96 97 if (section.dataset.boundTimeline !== "1") { 98 section.dataset.boundTimeline = "1"; 99 100 // mouse/pen only: a tap fires pointerover too, but it should select 101 scroller.addEventListener("pointerover", (e) => { 102 if (e.pointerType === "touch") return; 103 const index = indexOf(e.target); 104 if (index >= 0) preview(index); 105 }); 106 scroller.addEventListener("pointerleave", () => preview(-1)); 107 108 scroller.addEventListener("focusin", (e) => { 109 const index = indexOf(e.target); 110 if (index >= 0) preview(index); 111 }); 112 scroller.addEventListener("focusout", (e) => { 113 if (indexOf(e.relatedTarget) < 0) preview(-1); 114 }); 115 116 scroller.addEventListener("click", (e) => { 117 const lane = e.target.closest(".exp-lane"); 118 if (lane) { 119 // handled here, so the app-wide link handler leaves it alone 120 e.preventDefault(); 121 previewIndex = -1; 122 select(Number(lane.dataset.index), { scroll: true }); 123 return; 124 } 125 const index = indexOf(e.target); 126 if (index >= 0 && index !== selectedIndex) select(index); 127 }); 128 } 129 130 if (experienceHandlers.keydown) { 131 document.removeEventListener("keydown", experienceHandlers.keydown); 132 } 133 134 experienceHandlers.keydown = (e) => { 135 if (!section.isConnected || window.isLightboxOpen?.()) return; 136 if (isEditableTarget(e.target)) return; 137 138 let next = -1; 139 if (e.key === "ArrowUp") next = selectedIndex - 1; 140 else if (e.key === "ArrowDown") next = selectedIndex + 1; 141 else return; 142 143 e.preventDefault(); 144 if (next < 0 || next >= rows.length) return; 145 // the keyboard wins over a resting mouse until it moves again 146 previewIndex = -1; 147 select(next, { scroll: true }); 148 }; 149 150 document.addEventListener("keydown", experienceHandlers.keydown); 151 152 if (experienceHandlers.popstate) { 153 window.removeEventListener("popstate", experienceHandlers.popstate); 154 } 155 156 experienceHandlers.popstate = () => { 157 if (!section.isConnected) return; 158 const index = indexFromPath(); 159 if (index >= 0) select(index, { updateURL: false }); 160 }; 161 162 window.addEventListener("popstate", experienceHandlers.popstate); 163 164 section 165 .querySelectorAll(".exp-image-hero, .exp-image-thumb") 166 .forEach((img) => { 167 if (img.dataset.boundClick === "1") return; 168 img.dataset.boundClick = "1"; 169 170 img.addEventListener("click", () => { 171 const container = img.closest(".exp-card-images"); 172 const allImages = container 173 ? Array.from(container.querySelectorAll("img")).map((i) => i.src) 174 : []; 175 const startIndex = allImages.indexOf(img.src); 176 window.openLightbox?.(allImages, startIndex); 177 }); 178 }); 179 180 const pathIndex = indexFromPath(); 181 if (pathIndex >= 0) { 182 select(pathIndex, { updateURL: false }); 183 } else { 184 const rendered = rows.findIndex((row) => row.classList.contains("active")); 185 select(Math.max(rendered, 0)); 186 } 187 }