diff --git a/src/app/components/animateonscroll/animateonscroll.ts b/src/app/components/animateonscroll/animateonscroll.ts new file mode 100644 index 00000000000..2df209ae062 --- /dev/null +++ b/src/app/components/animateonscroll/animateonscroll.ts @@ -0,0 +1,168 @@ +import { CommonModule } from '@angular/common'; +import { AfterViewInit, Directive, ElementRef, Input, NgModule, Renderer2, OnInit } from '@angular/core'; +import { DomHandler } from 'primeng/dom'; + +interface AnimateOnScrollOptions { + root?: HTMLElement; + rootMargin?: string; + threshold?: number; +} + +/** + * AnimateOnScroll is used to apply animations to elements when entering or leaving the viewport during scrolling. + * @group Components + */ +@Directive({ + selector: '[pAnimateOnScroll]', + host: { + '[class.p-animateonscroll]': 'true' + } +}) +export class AnimateOnScroll implements OnInit, AfterViewInit { + /** + * Selector to define the CSS class for enter animation. + * @group Props + */ + @Input() enterClass: string | undefined; + /** + * Selector to define the CSS class for leave animation. + * @group Props + */ + @Input() leaveClass: string | undefined; + /** + * Specifies the root option of the IntersectionObserver API. + * @group Props + */ + @Input() root: HTMLElement | undefined | null; + /** + * Specifies the rootMargin option of the IntersectionObserver API. + * @group Props + */ + @Input() rootMargin: string | undefined; + /** + * Specifies the threshold option of the IntersectionObserver API + * @group Props + */ + @Input() threshold: number | undefined; + /** + * Whether the scroll event listener should be removed after initial run. + * @group Props + */ + @Input() once: boolean = true; + + observer: IntersectionObserver | undefined; + + resetObserver: any; + + isObserverActive: boolean = false; + + animationState: any; + + animationEndListener: VoidFunction | undefined; + + constructor(private host: ElementRef, public el: ElementRef, public renderer: Renderer2) {} + + ngOnInit() { + this.renderer.setStyle(this.host.nativeElement, 'opacity', this.enterClass ? '0' : ''); + } + + ngAfterViewInit() { + this.bindIntersectionObserver(); + } + + get options(): AnimateOnScrollOptions { + return { + root: this.root, + rootMargin: this.rootMargin, + threshold: this.threshold + } + } + + bindIntersectionObserver() { + this.observer = new IntersectionObserver(([entry]) => { + if(this.isObserverActive) { + if(entry.boundingClientRect.top > 0) { + entry.isIntersecting ? this.enter() : this.leave(); + } + } else if(entry.isIntersecting) { + this.enter(); + } + + this.isObserverActive = true; + }, this.options); + + setTimeout(() => this.observer.observe(this.host.nativeElement), 0); + + // Reset + + this.resetObserver = new IntersectionObserver(([entry]) => { + if (entry.boundingClientRect.top > 0 && !entry.isIntersecting) { + this.host.nativeElement.style.opacity = this.enterClass ? '0' : ''; + DomHandler.removeMultipleClasses(this.host.nativeElement, [this.enterClass, this.leaveClass]); + + this.resetObserver.unobserve(this.host.nativeElement); + } + + this.animationState = undefined; + }, {...this.options, threshold: 0}) + } + + enter() { + if (this.animationState !== 'enter' && this.enterClass) { + this.host.nativeElement.style.opacity = ''; + DomHandler.removeMultipleClasses(this.host.nativeElement, this.leaveClass); + DomHandler.addMultipleClasses(this.host.nativeElement, this.enterClass); + + this.once && this.unbindIntersectionObserver(); + + this.bindAnimationEvents(); + this.animationState = 'enter'; + } + } + + leave() { + if (this.animationState !== 'leave' && this.leaveClass) { + this.host.nativeElement.style.opacity = this.enterClass ? '0' : ''; + DomHandler.removeMultipleClasses(this.host.nativeElement, this.enterClass); + DomHandler.addMultipleClasses(this.host.nativeElement, this.leaveClass); + + this.bindAnimationEvents(); + this.animationState = 'leave'; + } + } + + bindAnimationEvents() { + if (!this.animationEndListener) { + this.animationEndListener = this.renderer.listen(this.host.nativeElement, 'animationend', () => { + DomHandler.removeMultipleClasses(this.host.nativeElement, [this.enterClass, this.leaveClass]); + !this.once && this.resetObserver.observe(this.host.nativeElement); + this.unbindAnimationEvents(); + }) + } + } + + unbindAnimationEvents() { + if (this.animationEndListener) { + this.animationEndListener(); + this.animationEndListener = null; + } + } + + unbindIntersectionObserver() { + this.observer?.unobserve(this.host.nativeElement); + this.resetObserver?.unobserve(this.host.nativeElement); + this.isObserverActive = false; + } + + ngOnDestroy() { + this.unbindAnimationEvents(); + this.unbindIntersectionObserver(); + } +} + +@NgModule({ + imports: [CommonModule], + exports: [AnimateOnScroll], + declarations: [AnimateOnScroll] +}) +export class AnimateOnScrollModule {} diff --git a/src/app/components/animateonscroll/ng-package.json b/src/app/components/animateonscroll/ng-package.json new file mode 100644 index 00000000000..0e529e387d7 --- /dev/null +++ b/src/app/components/animateonscroll/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "public_api.ts" + } +} \ No newline at end of file diff --git a/src/app/components/animateonscroll/public_api.ts b/src/app/components/animateonscroll/public_api.ts new file mode 100644 index 00000000000..594bf70f981 --- /dev/null +++ b/src/app/components/animateonscroll/public_api.ts @@ -0,0 +1 @@ +export * from './animateonscroll'; diff --git a/src/app/showcase/doc/animateonscroll/basicdoc.ts b/src/app/showcase/doc/animateonscroll/basicdoc.ts new file mode 100644 index 00000000000..0c006041ae9 --- /dev/null +++ b/src/app/showcase/doc/animateonscroll/basicdoc.ts @@ -0,0 +1,198 @@ +import { Component, Input } from '@angular/core'; +import { Code } from '../../domain/code'; + +@Component({ + selector: 'basic-doc', + template: `
+ +

Animation classes are defined with the enterClass and leaveClass properties. This example utilizes PrimeFlex animations however any valid CSS animation is supported.

+
+
+
+ Scroll Down + + + +
+
+
+ fade-in +
+
+
+ fade-left +
+
+
+ fade-right +
+
+
+ zoom +
+
+
+ flip-left +
+
+
+ flip-y +
+
+
+ scalein +
+
+ +
`, + styles: [ + ` + :host { + @keyframes slidedown-icon { + 0% { + transform: translateY(0); + } + + 50% { + transform: translateY(20px); + } + + 100% { + transform: translateY(0); + } + } + + .slidedown-icon { + animation: slidedown-icon; + animation-duration: 3s; + animation-iteration-count: infinite; + } + + .box { + background-image: radial-gradient(var(--primary-300), var(--primary-600)); + border-radius: 50% !important; + color: var(--primary-color-text); + } + } + ` + ] +}) +export class BasicDoc { + @Input() id: string; + + @Input() title: string; + + code: Code = { + basic: ` +
+ Scroll Down + + + +
+
+
+ fade-in +
+
+
+ fade-left +
+
+
+ fade-right +
+
+
+ zoom +
+
+
+ flip-left +
+
+
+ flip-y +
+
+
+ scalein +
`, + html: ` +
+
+ Scroll Down + + + +
+
+
+ fade-in +
+
+
+ fade-left +
+
+
+ fade-right +
+
+
+ zoom +
+
+
+ flip-left +
+
+
+ flip-y +
+
+
+ scalein +
+
`, + typescript: ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'animate-on-scroll-basic-demo', + templateUrl: './animate-on-scroll-basic-demo.html', + styles: [ + \` + :host { + @keyframes slidedown-icon { + 0% { + transform: translateY(0); + } + + 50% { + transform: translateY(20px); + } + + 100% { + transform: translateY(0); + } + } + + .slidedown-icon { + animation: slidedown-icon; + animation-duration: 3s; + animation-iteration-count: infinite; + } + + .box { + background-image: radial-gradient(var(--primary-300), var(--primary-600)); + border-radius: 50% !important; + color: var(--primary-color-text); + } + } + \` + ] +}) +export class AnimateOnScrollBasicDemo {}` + }; +} diff --git a/src/app/showcase/doc/animateonscroll/importdoc.ts b/src/app/showcase/doc/animateonscroll/importdoc.ts new file mode 100644 index 00000000000..f4264dd08c7 --- /dev/null +++ b/src/app/showcase/doc/animateonscroll/importdoc.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; +import { Code } from '../../domain/code'; + +@Component({ + selector: 'import-doc', + template: `
+ + +
` +}) +export class ImportDoc { + @Input() id: string; + + @Input() title: string; + + code: Code = { + typescript: `import { AnimateOnScrollModule } from 'primeng/animateonscroll';` + }; +} diff --git a/src/app/showcase/doc/apidoc/index.json b/src/app/showcase/doc/apidoc/index.json index 940d29058f2..74644a6efe2 100644 --- a/src/app/showcase/doc/apidoc/index.json +++ b/src/app/showcase/doc/apidoc/index.json @@ -332,6 +332,61 @@ } } }, + "animateonscroll": { + "components": { + "AnimateOnScroll": { + "description": "Animate manages PrimeFlex CSS classes declaratively to during enter/leave animations on scroll or on page load.", + "props": { + "description": "Defines the input properties of the component.", + "values": [ + { + "name": "enterClass", + "optional": false, + "readonly": false, + "type": "string", + "description": "Selector to define the CSS class for enter animation." + }, + { + "name": "leaveClass", + "optional": false, + "readonly": false, + "type": "string", + "description": "Selector to define the CSS class for leave animation." + }, + { + "name": "root", + "optional": false, + "readonly": false, + "type": "HTMLElement", + "description": "Specifies the root option of the IntersectionObserver API." + }, + { + "name": "rootMargin", + "optional": false, + "readonly": false, + "type": "string", + "description": "Specifies the rootMargin option of the IntersectionObserver API." + }, + { + "name": "threshold", + "optional": false, + "readonly": false, + "type": "number", + "description": "Specifies the threshold option of the IntersectionObserver API" + }, + { + "name": "once", + "optional": false, + "readonly": false, + "type": "boolean", + "default": "true", + "description": "Whether the scroll event listener should be removed after initial run." + } + ] + } + } + } + }, "blockableui": { "components": {} }, @@ -2312,6 +2367,13 @@ "readonly": false, "type": "number", "description": "Time to wait in milliseconds to hide the tooltip even it is active." + }, + { + "name": "id", + "optional": true, + "readonly": false, + "type": "string", + "description": "When present, it adds a custom id to the tooltip." } ] } @@ -11753,6 +11815,13 @@ "type": "string", "description": "Attribute of the image element." }, + { + "name": "loading", + "optional": false, + "readonly": false, + "type": "\"eager\" | \"lazy\"", + "description": "Attribute of the image element." + }, { "name": "appendTo", "optional": false, diff --git a/src/app/showcase/layout/app-routing.module.ts b/src/app/showcase/layout/app-routing.module.ts index a1017a986a9..d7f4225123f 100644 --- a/src/app/showcase/layout/app-routing.module.ts +++ b/src/app/showcase/layout/app-routing.module.ts @@ -115,7 +115,7 @@ const routes: Routes = [ { path: 'uikit', loadChildren: () => import('../pages/uikit/uikit.module').then((m) => m.UIKitModule) }, { path: 'autofocus', loadChildren: () => import('../pages/autofocus/autofocusdemo.module').then((m) => m.AutoFocusDemoModule) }, { path: 'overlay', loadChildren: () => import('../pages/overlay/overlaydemo.module').then((m) => m.OverlayDemoModule) }, - { path: 'animate', loadChildren: () => import('../pages/animate/animatedemo.module').then((m) => m.AnimateDemoModule) }, + { path: 'animateonscroll', loadChildren: () => import('../pages/animate/animateonscrolldemo.module').then((m) => m.AnimateOnScrollDemoModule) }, { path: 'templates', loadChildren: () => import('../pages/templates/templates.module').then((m) => m.TemplatesModule) } ] }, diff --git a/src/app/showcase/pages/animate/animateonscrolldemo-routing.module.ts b/src/app/showcase/pages/animate/animateonscrolldemo-routing.module.ts new file mode 100755 index 00000000000..b4907cf8138 --- /dev/null +++ b/src/app/showcase/pages/animate/animateonscrolldemo-routing.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { AnimateOnScrollDemo } from './animateonscrolldemo'; + +@NgModule({ + imports: [RouterModule.forChild([{ path: '', component: AnimateOnScrollDemo }])], + exports: [RouterModule] +}) +export class AnimateOnScrollDemoRoutingModule {} diff --git a/src/app/showcase/pages/animate/animateonscrolldemo.html b/src/app/showcase/pages/animate/animateonscrolldemo.html new file mode 100755 index 00000000000..4724daef07c --- /dev/null +++ b/src/app/showcase/pages/animate/animateonscrolldemo.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/showcase/pages/animate/animateonscrolldemo.module.ts b/src/app/showcase/pages/animate/animateonscrolldemo.module.ts new file mode 100755 index 00000000000..6799a65e560 --- /dev/null +++ b/src/app/showcase/pages/animate/animateonscrolldemo.module.ts @@ -0,0 +1,11 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { AnimateOnScrollDocModule } from '../../doc/animateonscroll/animateonscrolldoc.module'; +import { AnimateOnScrollDemoRoutingModule } from './animateonscrolldemo-routing.module'; +import { AnimateOnScrollDemo } from './animateonscrolldemo'; + +@NgModule({ + imports: [CommonModule, AnimateOnScrollDemoRoutingModule, AnimateOnScrollDocModule], + declarations: [AnimateOnScrollDemo] +}) +export class AnimateOnScrollDemoModule {} diff --git a/src/app/showcase/pages/animate/animateonscrolldemo.ts b/src/app/showcase/pages/animate/animateonscrolldemo.ts new file mode 100755 index 00000000000..299d2d4aaa3 --- /dev/null +++ b/src/app/showcase/pages/animate/animateonscrolldemo.ts @@ -0,0 +1,27 @@ +import { Component } from '@angular/core'; +import { ImportDoc } from '../../doc/animateonscroll/importdoc'; +import { BasicDoc } from '../../doc/animateonscroll/basicdoc'; +import { AccessibilityDoc } from '../../doc/animateonscroll/accessibilitydoc'; + +@Component({ + templateUrl: './animateonscrolldemo.html' +}) +export class AnimateOnScrollDemo { + docs = [ + { + id: 'import', + label: 'Import', + component: ImportDoc + }, + { + id: 'basic', + label: 'Basic', + component: BasicDoc + }, + { + id: 'accessibility', + label: 'Accessibility', + component: AccessibilityDoc + } + ]; +} diff --git a/src/assets/showcase/data/menu.json b/src/assets/showcase/data/menu.json index 46e4e1c918f..8db1d0711c0 100644 --- a/src/assets/showcase/data/menu.json +++ b/src/assets/showcase/data/menu.json @@ -447,8 +447,8 @@ "routerLink": "/autofocus" }, { - "name": "Animate", - "routerLink": "/animate" + "name": "AnimateOnScroll", + "routerLink": "/animateonscroll" } ] },