{"version":3,"file":"section-view-observer-section.min.js","mappings":"wZAmDA,MAAMA,EAAgB,IAAIC,IAqB1B,SAASC,EAAkBC,GACzB,MAAMC,EAAgBD,EAAQE,cAC5B,2CACF,IAAKD,EACH,OAGF,MAAME,EAAOF,EAAcG,QAAQC,YACnC,IAAKF,EACH,OAeF,MATmC,CACjCG,QAASN,EACTG,OACAI,gBAjCJ,SAA2BC,GACzB,MAEMC,EAAUC,WAAWF,GAC3B,OAAKG,OAAOC,SAASH,IAAYA,GAAW,EAHhB,EAOrBA,CACT,CAmByBI,CADIZ,EAAcG,QAAQU,cAO/CC,cAAe,KACfC,iBAAiB,EACjBC,UAAU,EAId,CAMA,SAASC,EAAmBC,GAC1B,IAAK,MAAMC,KAASD,EAAS,CAC3B,MAAME,EAAexB,EAAcyB,IAAIF,EAAMG,QACxCF,IAAgBA,EAAaJ,WAIlCI,EAAaL,gBAAkBI,EAAMI,eACrCH,EAAaN,cAAgBK,EAAMI,eAAiBC,KAAKC,MAAQ,KACnE,CACF,CAKA,SAASC,EAAkBN,EAA4BO,GAIrD,MAAMC,EAAQ,IAAIC,YAAoB,iBAAkB,CACtDC,SAAS,EACTC,UAAU,EACVC,OAAQ,CAAEC,aAAcb,EAAalB,KAAMgC,WAAYP,KAGzDP,EAAaf,QAAQ8B,cAAcP,EACrC,CAEA,SAASQ,IACP,GAAiC,WAA7BC,SAASC,gBACX,OAGF,MAAMb,EAAMD,KAAKC,MAEjB,IAAK,MAAML,KAAgBxB,EAAc2C,SAAU,CACjD,GAAInB,EAAaJ,UAA2C,OAA/BI,EAAaN,cACxC,SAGF,MAAMa,GAAWF,EAAML,EAAaN,eAAiB,IAEjDa,GAAWP,EAAad,kBAC1Bc,EAAaJ,UAAW,EACxBU,EAAkBN,EAAcO,GAEpC,CACF,CAMA,SAASa,EAAmBC,GAC1B,GAAiC,WAA7BJ,SAASC,gBAGX,IAAK,MAAMlB,KAAgBxB,EAAc2C,SACvCnB,EAAaN,cAAgB,SAE1B,CAIL,MAAMW,EAAMD,KAAKC,MACjB,IAAK,MAAML,KAAgBxB,EAAc2C,SACnCnB,EAAaL,kBAAoBK,EAAaJ,WAChDI,EAAaN,cAAgBW,EAGnC,CACF,ECpHO,SAAeiB,EACpBC,GACA,QAD2B,IAA3BA,IAAAA,EAA8B,oBACF,aAAxBN,SAASO,YAAuC,qBAATD,GACjB,gBAAxBN,SAASO,WAA+B,CACxC,MAAMC,EAAQC,WAAWJ,EAAU,GAEnC,MAD8B,IAAMK,aAAaF,EAEnD,CACEG,iBAAiBL,EAAMD,EAI3B,CDsJAO,CA5CA,WACE,MAAMC,EAAwBb,SAASc,iBAA8B,wBAErE,IAAK,MAAMC,KAAwBF,EAAuB,CACxD,MAAMnD,EAAUqD,EAAqBC,QAAQ,oBAC7C,IAAKtD,GAAWH,EAAc0D,IAAIvD,GAChC,SAGF,MAAMqB,EAAetB,EAAkBC,GACnCqB,GACFxB,EAAc2D,IAAIxD,EAASqB,EAE/B,CAEA,GAA2B,IAAvBxB,EAAc4D,KAChB,OASF,MAAMC,EAAW,IAAIC,qBAAqBzC,EACxC,CAAE0C,UAAW,EAAGC,WAAY,aAE9B,IAAK,MAAM7D,KAAWH,EAAciE,OAClCJ,EAASK,QAAQ/D,GAInBgE,YAAY3B,EADc,KAG1BC,SAASW,iBAAiB,mBAAoBR,EAChD","sources":["webpack://lange/./sections/section-view-observer/index.ts","webpack://lange/./lib/dom-ready.ts"],"sourcesContent":["import { ready } from '../../lib/dom-ready.js';\n\n// Section view observer — fires a `section-viewed` event on a section element after the user has\n// spent a configurable amount of time looking at it.\n//\n// ## How it works\n//\n// A section opts in by placing a child element with two data attributes:\n//   - `data-section-name`  — the name to include in the event payload\n//   - `data-section-dwell` — dwell time in seconds; must be present, value defaults to 5 if empty\n//     or invalid\n//\n// On page load this module scans for those sections and sets up three cooperating mechanisms:\n//\n// 1. **IntersectionObserver** — a browser API that fires a callback whenever an element crosses a\n//    viewport boundary, with no polling cost. Here it tracks whether each section is currently\n//    scrolled into the visible area. The observer is configured with a 10% inset so the dwell clock\n//    only starts once the section is meaningfully in view, not just peeking past the fold.\n//\n// 2. **Heartbeat timer** — a 1-second `setInterval` that checks every currently-visible section and\n//    fires `section-viewed` on any that have exceeded their dwell threshold. The observer alone\n//    cannot do this because it only fires on boundary crossings, not continuously.\n//\n// 3. **Visibility tracking** — a `visibilitychange` listener resets dwell timers when the tab is\n//    hidden and restarts them when it becomes visible again, so background tabs don't accumulate\n//    dwell time.\n//\n// Each section is reported at most once per page load\n\n/**\n * Dispatched on the section element once the dwell threshold is met\n **/\ntype SectionViewedEvent = CustomEvent<{\n  /** In seconds. */\n  dwell_time: number;\n  section_name: string;\n}>;\n\n/**\n * Internal runtime tracking state for a single observed section.\n **/\ninterface SectionState {\n  /** In seconds. */\n  dwell_threshold: number;\n  element: Element;\n  in_view_since: number | null;\n  is_intersecting: boolean;\n  name: string;\n  reported: boolean;\n}\n\nconst sectionStates = new Map<Element, SectionState>();\n\n/**\n * Parse a string value into a number of seconds, falling back to a default if the value is empty or\n * invalid\n */\nfunction parseDwellSeconds(value: string) {\n  const defaultDwellSeconds = 5;\n\n  const seconds = parseFloat(value);\n  if (!Number.isFinite(seconds) || seconds <= 0) {\n    return defaultDwellSeconds;\n  }\n\n  return seconds;\n}\n\n/**\n * Parse the section configuration from the DOM and build the initial SectionState. Return undefined\n * if the section is not configured correctly and should be ignored.\n */\nfunction buildSectionState(section: Element) {\n  const configElement = section.querySelector<HTMLElement>(\n    '[data-section-dwell][data-section-name]');\n  if (!configElement) {\n    return;\n  }\n\n  const name = configElement.dataset.sectionName;\n  if (!name) {\n    return;\n  }\n\n  const thresholdAttribute = configElement.dataset.sectionDwell;\n  const dwellThreshold = parseDwellSeconds(thresholdAttribute);\n\n  const sectionState: SectionState = {\n    element: section,\n    name,\n    dwell_threshold: dwellThreshold,\n    in_view_since: null,\n    is_intersecting: false,\n    reported: false\n  };\n\n  return sectionState;\n}\n\n/**\n * Handle intersection changes for all observed sections. Start or stop the dwell timer for each\n * section based on whether it is intersecting the viewport.\n */\nfunction handleIntersection(entries: IntersectionObserverEntry[]) {\n  for (const entry of entries) {\n    const sectionState = sectionStates.get(entry.target);\n    if (!sectionState || sectionState.reported) {\n      continue;\n    }\n\n    sectionState.is_intersecting = entry.isIntersecting;\n    sectionState.in_view_since = entry.isIntersecting ? Date.now() : null;\n  }\n}\n\n/**\n * Emit a custom event with the section name and dwell time\n */\nfunction emitSectionViewed(sectionState: SectionState, elapsed: number) {\n  type Detail = SectionViewedEvent['detail'];\n\n  // composed: true lets the event cross shadow root boundaries\n  const event = new CustomEvent<Detail>('section-viewed', {\n    bubbles: true,\n    composed: true,\n    detail: { section_name: sectionState.name, dwell_time: elapsed }\n  });\n\n  sectionState.element.dispatchEvent(event);\n}\n\nfunction reportDueSections() {\n  if (document.visibilityState === 'hidden') {\n    return;\n  }\n\n  const now = Date.now();\n\n  for (const sectionState of sectionStates.values()) {\n    if (sectionState.reported || sectionState.in_view_since === null) {\n      continue;\n    }\n\n    const elapsed = (now - sectionState.in_view_since) / 1_000;\n\n    if (elapsed >= sectionState.dwell_threshold) {\n      sectionState.reported = true;\n      emitSectionViewed(sectionState, elapsed);\n    }\n  }\n}\n\n/**\n * Change the section state when the page visibility changes to ensure that sections are only\n * reported as viewed when the page is actually visible to the user\n */\nfunction onVisibilityChange(_event: Event) {\n  if (document.visibilityState === 'hidden') {\n    // When the browser or its tab is hidden, reset all timers to prevent sections from being\n    // reported as viewed\n    for (const sectionState of sectionStates.values()) {\n      sectionState.in_view_since = null;\n    }\n  } else {\n    // When the browser or its tab becomes visible again, set the inViewSince time for all currently\n    // intersecting sections to now, so that the dwell time is only counted while the page is\n    // visible\n    const now = Date.now();\n    for (const sectionState of sectionStates.values()) {\n      if (sectionState.is_intersecting && !sectionState.reported) {\n        sectionState.in_view_since = now;\n      }\n    }\n  }\n}\n\nfunction onReady() {\n  const sectionConfigElements = document.querySelectorAll<HTMLElement>('[data-section-dwell]');\n\n  for (const sectionConfigElement of sectionConfigElements) {\n    const section = sectionConfigElement.closest('.shopify-section');\n    if (!section || sectionStates.has(section)) {\n      continue;\n    }\n\n    const sectionState = buildSectionState(section);\n    if (sectionState) {\n      sectionStates.set(section, sectionState);\n    }\n  }\n\n  if (sectionStates.size === 0) {\n    return;\n  }\n\n  // rootMargin shrinks the observable area by 10% at the top and bottom of the viewport. A section\n  // must scroll 10% past the fold before its dwell clock starts — so a section barely peeking past\n  // the fold doesn't count as viewed.\n  //\n  // threshold: 0 fires on the first pixel crossing that shrunken boundary. A higher value would\n  // require a proportional area of the section to be visible, which breaks for tall sections.\n  const observer = new IntersectionObserver(handleIntersection,\n    { threshold: 0, rootMargin: '-10% 0px' });\n\n  for (const section of sectionStates.keys()) {\n    observer.observe(section);\n  }\n\n  const heartbeatInterval = 1_000;\n  setInterval(reportDueSections, heartbeatInterval);\n\n  document.addEventListener('visibilitychange', onVisibilityChange);\n}\n\ndeclare global {\n  interface WindowEventMap {\n    'section-viewed': SectionViewedEvent;\n  }\n}\n\nready(onReady);\n","/**\n * Releases the handle returned from the ready() function.\n *\n * This is idempotent. Additional calls are pointless but harmless.\n */\nexport type ReleaseHandle = () => void;\n\n/**\n * A subset of events that pertain to document ready state.\n */\ntype LoadingEventMap = Pick<DocumentEventMap, 'DOMContentLoaded' | 'load'>;\n\n/**\n * Calls the provided callback when the document is ready. This works both when the document is\n * already ready and when it is not, so callers do not have to care. This exists because it ensures\n * that each caller consistently expresses its dependency on document readiness, and to save a bit\n * of boilerplate.\n *\n * A document is considered ready when it has been parsed. This corresponds to the ready state of\n * 'interactive'. When the document has been fully loaded, the ready state is 'complete'. The\n * callback runs either way, so callers do not have to check which one they are in.\n *\n * Ready means the html is parsed and the DOM is there to query. It does not mean the page's other\n * scripts have run. Deferred scripts and module scripts run after the ready state turns\n * 'interactive' and before DOMContentLoaded fires, so a callback here can run before them. When a\n * caller needs some other script to have run already, this is the wrong tool, and it should wait on\n * whatever that script produces instead.\n *\n * That remedy has a catch of its own when the calling bundle is loaded with async. An async bundle\n * can execute after DOMContentLoaded has already fired, so a plain listener for that event never\n * runs and the work silently never happens. Nothing here can detect that, because by the time this\n * file executes the event is long gone. Catching it needs something registered at parse time,\n * before any bundle loads.\n *\n * There is a second trap right next to that one, and it applies only when waiting for\n * DOMContentLoaded. The ready state stays 'interactive' after DOMContentLoaded has fired, all the\n * way until 'complete', so the ready state cannot tell you whether DOMContentLoaded is still coming\n * or has already gone. Waiting for load is not exposed to this, because the ready state turns\n * 'complete' and load fires in the same task, so seeing 'complete' means load has fired too.\n *\n * When the document is already ready, the callback is invoked via setTimeout. This way, if the\n * callback throws, the error surfaces to the global error handler in a way that is similar to what\n * happens when an event listener throws.\n *\n * We might one day want to move this to an NPM library.\n *\n * Beware of a quirk here. setTimeout yields, and the busier the frontend, the longer it takes to\n * fire. Page load is exactly when the frontend is busiest. The timeout is a floor and not a\n * guarantee, so there is no real bound on how long that yield lasts. What this means is that using\n * this library can introduce more delay than you might expect. Compare code that runs on async load\n * and checks ready state and invokes immediately to code that runs after setTimeout. The former\n * runs in the same tick, the latter runs in \"some later tick\". That later tick can be surprisingly\n * much later. That can cause initial paint issues.\n *\n * @see https://developer.mozilla.org/en-US/docs/Web/API/Document/readyState\n * @see https://developer.mozilla.org/en-US/docs/Web/API/Document/DOMContentLoaded_event\n * @see https://html.spec.whatwg.org/multipage/parsing.html#the-end\n */\nexport function ready(callback: (event?: Event) => void,\n  type: keyof LoadingEventMap = 'DOMContentLoaded') {\n  if (document.readyState === 'complete' || (type === 'DOMContentLoaded' &&\n    document.readyState === 'interactive')) {\n    const timer = setTimeout(callback, 0);\n    const handle: ReleaseHandle = () => clearTimeout(timer);\n    return handle;\n  } else {\n    addEventListener(type, callback);\n    const handle: ReleaseHandle = () => removeEventListener(type, callback);\n    return handle;\n  }\n}\n"],"names":["sectionStates","Map","buildSectionState","section","configElement","querySelector","name","dataset","sectionName","element","dwell_threshold","value","seconds","parseFloat","Number","isFinite","parseDwellSeconds","sectionDwell","in_view_since","is_intersecting","reported","handleIntersection","entries","entry","sectionState","get","target","isIntersecting","Date","now","emitSectionViewed","elapsed","event","CustomEvent","bubbles","composed","detail","section_name","dwell_time","dispatchEvent","reportDueSections","document","visibilityState","values","onVisibilityChange","_event","callback","type","readyState","timer","setTimeout","clearTimeout","addEventListener","ready","sectionConfigElements","querySelectorAll","sectionConfigElement","closest","has","set","size","observer","IntersectionObserver","threshold","rootMargin","keys","observe","setInterval"],"sourceRoot":""}