diff --git a/build/index.js b/build/index.js index 5029133..9df6773 100644 --- a/build/index.js +++ b/build/index.js @@ -1 +1 @@ -class ObserveTriggers{constructor(config={}){this.config={baseTriggerClass:"observe-trigger",baseTriggeredClass:"observe-triggered",...config};this.observers=new Map;this.elementStates=new WeakMap;this.init()}init(){this.observeElements();window.addEventListener("DOMContentLoaded",(()=>this.observeElements()));window.addEventListener("load",(()=>this.observeElements()))}parseObserverClass(className){const parts=className.replace(this.config.baseTriggerClass+"-","").split("-");const config={rootMargin:0,threshold:0,edge:"top",action:"toggle",class:"observe-triggered",root:null};let currentPart=0;if(!isNaN(parts[currentPart])){config.rootMargin=parseInt(parts[currentPart]);currentPart++}if(!isNaN(parts[currentPart])){config.threshold=parseInt(parts[currentPart])/100;currentPart++}if(["top","bottom","left","right"].includes(parts[currentPart])){config.edge=parts[currentPart];currentPart++}if(["toggle","add","remove","replace"].includes(parts[currentPart])){config.action=parts[currentPart];currentPart++}const remainingParts=parts.slice(currentPart);const rootIndex=remainingParts.findIndex((part=>part.startsWith("#")||part.startsWith(".")));if(rootIndex!==-1){config.class=remainingParts.slice(0,rootIndex).join("-");config.root=remainingParts.slice(rootIndex).join("-")}else{config.class=remainingParts.join("-")||config.class}return config}observeElements(){document.querySelectorAll(`[class*="${this.config.baseTriggerClass}"]`).forEach((element=>{const classes=Array.from(element.classList);classes.forEach((className=>{if(className.startsWith(this.config.baseTriggerClass)){this.setupObserver(element,className)}}))}))}setupObserver(element,className){const config=this.parseObserverClass(className);const rootMargin=100-parseInt(config.rootMargin);const options={root:null,threshold:config.threshold};if("top"===config.edge){options.rootMargin="50% 0px -"+rootMargin+"% 0px"}else if("bottom"===config.edge){options.rootMargin="-"+rootMargin+"% 0px 50% 0px"}else if("left"===config.edge){options.rootMargin="0px -"+rootMargin+"% 0px 50%"}else if("right"===config.edge){options.rootMargin="0px 50% 0px -"+rootMargin+"%"}const observer=new IntersectionObserver((entries=>{entries.forEach((entry=>{this.handleIntersection(element,entry,config,className)}))}),options);observer.observe(element);if(!this.observers.has(element)){this.observers.set(element,new Map)}this.observers.get(element).set(className,observer,false)}handleIntersection(element,entry,config,className){const elementStates=this.elementStates.get(element)||new Map;const hasTriggered=elementStates.get(className);const isTriggered=entry.intersectionRatio>config.threshold;if(entry.isIntersecting||hasTriggered){switch(config.action){case"add":if(entry.isIntersecting&&isTriggered){element.classList.add(config.class)}break;case"remove":if(entry.isIntersecting&&isTriggered){element.classList.remove(config.class)}break;case"replace":element.classList.forEach((otherClass=>{if(otherClass.startsWith(this.config.baseTriggerClass)){const otherConfig=this.parseObserverClass(otherClass);if(otherConfig.class!==config.class){element.classList.remove(otherConfig.class)}}}));element.classList.add(config.class);break;case"toggle":default:element.classList.toggle(config.class,isTriggered);break}}if(config.action.includes(["add","remove"])&&isTriggered){this.disconnectObserver(element,className)}if(!hasTriggered&&isTriggered){elementStates.set(className,true)}else{elementStates.set(className,false)}this.elementStates.set(element,elementStates);this.dispatchEvent(element,isTriggered,config,className)}disconnectObserver(element,className){const observers=this.observers.get(element);if(observers&&observers.has(className)){observers.get(className).disconnect();observers.delete(className);if(observers.size===0){this.observers.delete(element)}}}dispatchEvent(element,isIntersecting,config,className){const event=new CustomEvent("observerTriggered",{detail:{element:element,isIntersecting:isIntersecting,config:config,className:className}});window.dispatchEvent(event)}destroy(){this.observers.forEach(((observers,element)=>{observers.forEach((observer=>observer.disconnect()))}));this.observers.clear();this.elementStates.clear()}}export default ObserveTriggers; \ No newline at end of file +class ObserveTriggers{constructor(config={}){this.config={baseTriggerClass:"observe-trigger",baseTriggeredClass:"observe-triggered",...config};this.observers=new Map;this.elementStates=new WeakMap;this.scrollers=new Map;this.scrollersActive=false;this.handleScrollers=this.handleScrollers.bind(this);this.viewportHeight=false;this.init()}init(){this.observeElements();window.addEventListener("DOMContentLoaded",(()=>this.observeElements()));window.addEventListener("load",(()=>this.observeElements()))}parseObserverClass(className){const parts=className.replace(this.config.baseTriggerClass+"-","").split("-");const config={rootMargin:0,threshold:0,edge:"top",action:"toggle",class:"observe-triggered",root:null};let currentPart=0;if(!isNaN(parts[currentPart])){config.rootMargin=parseInt(parts[currentPart]);currentPart++}if(!isNaN(parts[currentPart])){config.threshold=parseInt(parts[currentPart])/100;currentPart++}if(["top","bottom","left","right"].includes(parts[currentPart])){config.edge=parts[currentPart];currentPart++}if(["toggle","add","remove","replace","scroll"].includes(parts[currentPart])){config.action=parts[currentPart];currentPart++}const remainingParts=parts.slice(currentPart);const rootIndex=remainingParts.findIndex((part=>part.startsWith("#")||part.startsWith(".")));if(rootIndex!==-1){config.class=remainingParts.slice(0,rootIndex).join("-");config.root=remainingParts.slice(rootIndex).join("-")}else{config.class=remainingParts.join("-")||config.class}return config}observeElements(){document.querySelectorAll(`[class*="${this.config.baseTriggerClass}"]`).forEach((element=>{const classes=Array.from(element.classList);classes.forEach((className=>{if(className.startsWith(this.config.baseTriggerClass)){this.setupObserver(element,className)}}))}))}setupObserver(element,className){const config=this.parseObserverClass(className);const rootMargin=100-parseInt(config.rootMargin);const options={root:null,threshold:config.threshold};if("top"===config.edge){options.rootMargin="50% 0px -"+rootMargin+"% 0px"}else if("bottom"===config.edge){options.rootMargin="-"+rootMargin+"% 0px 50% 0px"}else if("left"===config.edge){options.rootMargin="0px -"+rootMargin+"% 0px 50%"}else if("right"===config.edge){options.rootMargin="0px 50% 0px -"+rootMargin+"%"}const observer=new IntersectionObserver((entries=>{entries.forEach((entry=>{this.handleIntersection(element,entry,config,className)}))}),options);observer.observe(element);if(!this.observers.has(element)){this.observers.set(element,new Map)}this.observers.get(element).set(className,observer,false)}handleScrollers(scrollEvent){if(!this.scrollers){return}this.scrollers.forEach((({classes:classes,config:config},element)=>{if(false===this.viewportHeight){this.viewportHeight=window.innerHeight}if(classes.includes("observe-scroll")){let hasScrolledAmount=this.viewportHeight*(config.rootMargin/100)-element.getBoundingClientRect().top;if(hasScrolledAmount<0){hasScrolledAmount=0}element.style.setProperty("--distance-from-trigger",hasScrolledAmount)}}))}handleIntersection(element,entry,config,className){const elementStates=this.elementStates.get(element)||new Map;const hasTriggered=elementStates.get(className);const isTriggered=entry.intersectionRatio>config.threshold;if(entry.isIntersecting||hasTriggered){switch(config.action){case"add":if(entry.isIntersecting&&isTriggered){element.classList.add(config.class)}break;case"remove":if(entry.isIntersecting&&isTriggered){element.classList.remove(config.class)}break;case"replace":element.classList.forEach((otherClass=>{if(otherClass.startsWith(this.config.baseTriggerClass)){const otherConfig=this.parseObserverClass(otherClass);if(otherConfig.class!==config.class){element.classList.remove(otherConfig.class)}}}));element.classList.add(config.class);break;case"scroll":if(entry.isIntersecting&&!this.scrollers.has(element)&&isTriggered){const scrollerClasses=[];element.classList.forEach((className=>{if(className.startsWith("observe-scroll")){scrollerClasses.push(className)}}));this.scrollers.set(element,{classes:scrollerClasses,config:config})}else if(!entry.isIntersecting&&this.scrollers.has(element)){this.scrollers.delete(element)}if(this.scrollers.size>0&&!this.scrollersActive){window.addEventListener("scroll",this.handleScrollers,{passive:true});this.scrollersActive=true}else if(this.scrollers.size===0){window.removeEventListener("scroll",this.handleScrollers,{passive:true});this.scrollersActive=false}break;case"toggle":default:element.classList.toggle(config.class,isTriggered);break}}if(config.action.includes(["add","remove"])&&isTriggered){this.disconnectObserver(element,className)}if(!hasTriggered&&isTriggered){elementStates.set(className,true)}else{elementStates.set(className,false)}this.elementStates.set(element,elementStates);this.dispatchEvent(element,isTriggered,config,className)}disconnectObserver(element,className){const observers=this.observers.get(element);if(observers&&observers.has(className)){observers.get(className).disconnect();observers.delete(className);if(observers.size===0){this.observers.delete(element)}}}dispatchEvent(element,isIntersecting,config,className){const event=new CustomEvent("observerTriggered",{detail:{element:element,isIntersecting:isIntersecting,config:config,className:className}});window.dispatchEvent(event)}destroy(){this.observers.forEach(((observers,element)=>{observers.forEach((observer=>observer.disconnect()))}));this.observers.clear();this.elementStates.clear()}}export default ObserveTriggers; \ No newline at end of file diff --git a/demo/css/style.css b/demo/css/style.css index d86f7a6..6a37f49 100644 --- a/demo/css/style.css +++ b/demo/css/style.css @@ -6,7 +6,11 @@ body { .container { position: relative; - padding: 100vh 0; /* Add padding to allow scrolling */ + padding: 100vh 0 200vh 0; /* Add padding to allow scrolling */ +} + +.scrollers .container { + padding-top: 20vh; } .horizontal-line { @@ -36,8 +40,13 @@ body { position: relative; } +.double-box { + height: 200px; +} + +.demo-box::before, .demo-box::after { - content: attr(class); + content: attr(style); position: absolute; bottom: -20px; left: 0; @@ -50,9 +59,26 @@ body { background-color: rgba(255, 255, 255, 0.8); padding: 2px; border-radius: 3px; + width: fit-content; +} + +.demo-box::after { + content: attr(class); + top: -20px; + bottom: initial; } .observe-triggered { transform: scale(1.1); box-shadow: 0 0 10px rgba(0,0,0,0.5); } + +.animated-left { + transform: translateX( calc( var( --distance-from-trigger ) * -1px ) ); + transition: transform 50ms linear; +} + +.animated-right { + transform: translateX( calc( var( --distance-from-trigger ) * 1px ) ); + transition: transform 50ms linear; +} diff --git a/demo/scrollers.html b/demo/scrollers.html new file mode 100644 index 0000000..ef4735f --- /dev/null +++ b/demo/scrollers.html @@ -0,0 +1,46 @@ + + + + + + Observe Triggers Demo + + + + + + +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+ + + + diff --git a/src/index.js b/src/index.js index 38545e9..1ca9ea9 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,10 @@ class ObserveTriggers { }; this.observers = new Map(); this.elementStates = new WeakMap(); + this.scrollers = new Map(); + this.scrollersActive = false; + this.handleScrollers = this.handleScrollers.bind(this); + this.viewportHeight = false; this.init(); } @@ -32,7 +36,7 @@ class ObserveTriggers { * - baseTriggerClass: The base class name that triggers the observer. * - rootMargin: The root margin for the observer. * - edge: (Optional, default top) The edge to observe: top, bottom, left, right. - * - action: (Optional, default toggle) The action to perform: toggle, add, remove, replace. + * - action: (Optional, default toggle) The action to perform: toggle, add, remove, replace, scroll. * - class: (Optional, default observe-triggered) The class to add, toggle, or remove. * - root: (Optional, default null) The root element to observe. * @@ -76,7 +80,9 @@ class ObserveTriggers { // Parse for a specified action, if it exists. if ( - ['toggle', 'add', 'remove', 'replace'].includes(parts[currentPart]) + ['toggle', 'add', 'remove', 'replace', 'scroll'].includes( + parts[currentPart] + ) ) { config.action = parts[currentPart]; currentPart++; @@ -162,6 +168,40 @@ class ObserveTriggers { this.observers.get(element).set(className, observer, false); } + /** + * Handle scrollers. + * + * @param {Event} scrollEvent The scroll event. + */ + handleScrollers(scrollEvent) { + if (!this.scrollers) { + return; + } + + this.scrollers.forEach(({ classes, config }, element) => { + if (false === this.viewportHeight) { + this.viewportHeight = window.innerHeight; + } + + // If `observe-scroll` is present, calculate the distance from the top of the viewport to the top of the scroller. + if (classes.includes('observe-scroll')) { + // Calculate the distance from the top of the viewport to the top of the scroller. + let hasScrolledAmount = + this.viewportHeight * (config.rootMargin / 100) - + element.getBoundingClientRect().top; + + if (hasScrolledAmount < 0) { + hasScrolledAmount = 0; + } + + element.style.setProperty( + '--distance-from-trigger', + hasScrolledAmount + ); + } + }); + } + /** * Handle an observed intersection. * @@ -213,6 +253,51 @@ class ObserveTriggers { } }); element.classList.add(config.class); + break; + case 'scroll': + if ( + entry.isIntersecting && + !this.scrollers.has(element) && + isTriggered + ) { + const scrollerClasses = []; + + // Capture any class names that start with `observe-scroll`. + element.classList.forEach((className) => { + if (className.startsWith('observe-scroll')) { + scrollerClasses.push(className); + } + }); + + // Add this scroller to the scrollers map. + this.scrollers.set(element, { + classes: scrollerClasses, + config, + }); + } else if ( + !entry.isIntersecting && + this.scrollers.has(element) + ) { + this.scrollers.delete(element); + } + + // If there are intersecting scrollers, setup a scroll event listener. + if (this.scrollers.size > 0 && !this.scrollersActive) { + window.addEventListener( + 'scroll', + this.handleScrollers, + { passive: true } + ); + this.scrollersActive = true; + } else if (this.scrollers.size === 0) { + window.removeEventListener( + 'scroll', + this.handleScrollers, + { passive: true } + ); + this.scrollersActive = false; + } + break; case 'toggle': default: