diff --git a/packages/experiment-tag/src/message-bus.ts b/packages/experiment-tag/src/message-bus.ts index 14d27fd7..c9d12646 100644 --- a/packages/experiment-tag/src/message-bus.ts +++ b/packages/experiment-tag/src/message-bus.ts @@ -17,6 +17,10 @@ export type ElementVisiblePayload = { mutationList: MutationRecord[] }; export type AnalyticsEventPayload = AnalyticsEvent; export type ManualTriggerPayload = { name: string }; export type UrlChangePayload = { updateActivePages?: boolean }; +export type ScrolledToPayload = { + scrollPercentage: number; + elementAndOffset: Set; // Keys like "selector:offsetPx" for elements in range +}; export type MessagePayloads = { element_appeared: ElementAppearedPayload; @@ -24,6 +28,7 @@ export type MessagePayloads = { url_change: UrlChangePayload; analytics_event: AnalyticsEventPayload; manual: ManualTriggerPayload; + scrolled_to: ScrolledToPayload; }; export type MessageType = keyof MessagePayloads; diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index c62c3b9b..5c805fc0 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -7,6 +7,7 @@ import { ElementAppearedPayload, ManualTriggerPayload, MessageType, + ScrolledToPayload, } from './message-bus'; import { DebouncedMutationManager } from './mutation-manager'; import { @@ -15,6 +16,7 @@ import { ManualTriggerValue, PageObject, PageObjects, + ScrolledToTriggerValue, } from './types'; const evaluationEngine = new EvaluationEngine(); @@ -66,6 +68,7 @@ export class SubscriptionManager { } this.setupMutationObserverPublisher(); this.setupVisibilityPublisher(); + this.setupScrolledToPublisher(); this.setupPageObjectSubscriptions(); this.setupUrlChangeReset(); // Initial check for elements that already exist @@ -413,6 +416,106 @@ export class SubscriptionManager { wrapHistoryMethods(); }; + private setupScrolledToPublisher = () => { + // Collect static list of scroll targets from page objects + let minPercentage: number | undefined = undefined; + const elementTargets: Array<{ selector: string; offsetPx: number }> = []; + + for (const pages of Object.values(this.pageObjects)) { + for (const page of Object.values(pages)) { + if (page.trigger_type === 'scrolled_to') { + const triggerValue = page.trigger_value as ScrolledToTriggerValue; + + if (triggerValue.mode === 'percent') { + minPercentage = + minPercentage === undefined + ? triggerValue.percentage + : Math.min(minPercentage, triggerValue.percentage); + } else if (triggerValue.mode === 'element') { + const offset = triggerValue.offsetPx || 0; + // Add if not already present + if ( + !elementTargets.some( + (t) => + t.selector === triggerValue.selector && t.offsetPx === offset, + ) + ) { + elementTargets.push({ + selector: triggerValue.selector, + offsetPx: offset, + }); + } + } + } + } + } + + // Skip setup if no scroll triggers + if (minPercentage === undefined && elementTargets.length === 0) { + return; + } + + const handleScroll = ( + minPercent: number | undefined, + elements: Array<{ selector: string; offsetPx: number }>, + ) => { + const scrollPercentage = this.calculateScrollPercentage(); + const elementAndOffset = new Set(); + + // Check which elements are in range + for (const { selector, offsetPx } of elements) { + const element = this.globalScope.document.querySelector(selector); + if (element) { + const elementPosition = element.getBoundingClientRect().top; + const scrollPosition = this.globalScope.scrollY; + + if ( + scrollPosition + this.globalScope.innerHeight >= + elementPosition + scrollPosition + offsetPx + ) { + const key = `${selector}:${offsetPx}`; + elementAndOffset.add(key); + } + } + } + + // Publish if minimum percentage threshold is met or any element is in range + const shouldPublish = + (minPercent !== undefined && scrollPercentage >= minPercent) || + elementAndOffset.size > 0; + + if (shouldPublish) { + this.messageBus.publish('scrolled_to', { + scrollPercentage, + elementAndOffset, + }); + } + }; + + this.globalScope.addEventListener( + 'scroll', + () => handleScroll(minPercentage, elementTargets), + { passive: true }, + ); + + // Check immediately in case user is already scrolled + handleScroll(minPercentage, elementTargets); + }; + + private calculateScrollPercentage(): number { + const windowHeight = this.globalScope.innerHeight; + const documentHeight = + this.globalScope.document.documentElement.scrollHeight; + const scrollTop = this.globalScope.scrollY; + const scrollableHeight = documentHeight - windowHeight; + + if (scrollableHeight <= 0) { + return 100; + } + + return (scrollTop / scrollableHeight) * 100; + } + private isPageObjectActive = ( page: PageObject, message: MessagePayloads[T], @@ -501,6 +604,23 @@ export class SubscriptionManager { return this.elementVisibilityState.get(observerKey) ?? false; } + case 'scrolled_to': { + const triggerValue = page.trigger_value as ScrolledToTriggerValue; + const scrollPayload = message as ScrolledToPayload; + const currentScrollPercentage = scrollPayload.scrollPercentage; + const elementAndOffset = scrollPayload.elementAndOffset; + + if (triggerValue.mode === 'percent') { + return currentScrollPercentage >= triggerValue.percentage; + } else if (triggerValue.mode === 'element') { + const offset = triggerValue.offsetPx || 0; + const key = `${triggerValue.selector}:${offset}`; + return elementAndOffset.has(key); + } + + return false; + } + default: return false; } diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 42639506..d0ffaeb2 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -45,6 +45,21 @@ export interface ManualTriggerValue { name: string; } +export interface ScrolledToElementConfig { + mode: 'element'; + selector: string; + offsetPx?: number; +} + +export interface ScrolledToPercentConfig { + mode: 'percent'; + percentage: number; +} + +export type ScrolledToTriggerValue = + | ScrolledToElementConfig + | ScrolledToPercentConfig; + export type PageObject = { id: string; name: string; @@ -54,6 +69,7 @@ export type PageObject = { | ElementAppearedTriggerValue | ElementVisibleTriggerValue | ManualTriggerValue + | ScrolledToTriggerValue | Record; };