prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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 }