diff --git a/projects/igniteui-angular/directives/src/directives/ripple/ripple.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/ripple/ripple.directive.spec.ts new file mode 100644 index 00000000000..95c4e8d742c --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/ripple/ripple.directive.spec.ts @@ -0,0 +1,262 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxRippleDirective } from './ripple.directive'; +import { IgxButtonDirective } from '../button/button.directive'; + +describe('IgxRipple', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + RippleButtonComponent, + RippleDisabledComponent, + RippleCenteredComponent, + RippleColorComponent, + RippleTargetComponent + ] + }).compileComponents(); + })); + + it('Should initialize ripple directive on button', () => { + const fixture = TestBed.createComponent(RippleButtonComponent); + fixture.detectChanges(); + + const button = fixture.debugElement.query(By.css('button')); + const rippleDirective = button.injector.get(IgxRippleDirective); + + expect(rippleDirective).toBeTruthy(); + expect(button.nativeElement).toBeTruthy(); + }); + + it('Should not affect host element size when ripple is triggered without CSS styles', () => { + const fixture = TestBed.createComponent(RippleButtonComponent); + fixture.detectChanges(); + + const buttonDebug = fixture.debugElement.query(By.css('button')); + const button = buttonDebug.nativeElement; + const rippleDirective = buttonDebug.injector.get(IgxRippleDirective); + + // Set explicit dimensions to ensure we can measure them + button.style.width = '100px'; + button.style.height = '40px'; + button.style.padding = '10px'; + fixture.detectChanges(); + + const initialWidth = button.offsetWidth; + const initialHeight = button.offsetHeight; + + expect(initialWidth).toBeGreaterThan(0); + expect(initialHeight).toBeGreaterThan(0); + + const setStylesSpy = spyOn(rippleDirective, 'setStyles').and.callThrough(); + const rect = button.getBoundingClientRect(); + const mouseEvent = new MouseEvent('mousedown', { + clientX: rect.left + 50, + clientY: rect.top + 20, + bubbles: true + }); + + rippleDirective.onMouseDown(mouseEvent); + + expect(setStylesSpy).toHaveBeenCalled(); + + const rippleElement = setStylesSpy.calls.mostRecent().args[0] as HTMLElement; + + expect(rippleElement.style.position).toBe('absolute'); + + const afterWidth = button.offsetWidth; + const afterHeight = button.offsetHeight; + + expect(afterWidth).toBe(initialWidth); + expect(afterHeight).toBe(initialHeight); + }); + + it('Should create ripple element with position absolute style', () => { + const fixture = TestBed.createComponent(RippleButtonComponent); + fixture.detectChanges(); + + const buttonDebug = fixture.debugElement.query(By.css('button')); + const rippleDirective = buttonDebug.injector.get(IgxRippleDirective); + const setStyleSpy = spyOn(rippleDirective['renderer'], 'setStyle').and.callThrough(); + const button = buttonDebug.nativeElement; + const rect = button.getBoundingClientRect(); + const mouseEvent = new MouseEvent('mousedown', { + clientX: rect.left + 10, + clientY: rect.top + 10, + bubbles: true + }); + + rippleDirective.onMouseDown(mouseEvent); + + const positionStyleCall = setStyleSpy.calls.all().find(call => + call.args[1] === 'position' && call.args[2] === 'absolute' + ); + expect(positionStyleCall).toBeTruthy(); + }); + + it('Should not create ripple when disabled', () => { + const fixture = TestBed.createComponent(RippleDisabledComponent); + fixture.detectChanges(); + + const buttonDebug = fixture.debugElement.query(By.css('button')); + const rippleDirective = buttonDebug.injector.get(IgxRippleDirective); + const setStylesSpy = spyOn(rippleDirective, 'setStyles'); + const button = buttonDebug.nativeElement; + const rect = button.getBoundingClientRect(); + const mouseEvent = new MouseEvent('mousedown', { + clientX: rect.left + 10, + clientY: rect.top + 10, + bubbles: true + }); + + rippleDirective.onMouseDown(mouseEvent); + expect(setStylesSpy).not.toHaveBeenCalled(); + }); + + it('Should apply custom ripple color as background style', () => { + const fixture = TestBed.createComponent(RippleColorComponent); + fixture.detectChanges(); + + const buttonDebug = fixture.debugElement.query(By.css('button')); + const rippleDirective = buttonDebug.injector.get(IgxRippleDirective); + const setStyleSpy = spyOn(rippleDirective['renderer'], 'setStyle').and.callThrough(); + const button = buttonDebug.nativeElement; + const rect = button.getBoundingClientRect(); + const mouseEvent = new MouseEvent('mousedown', { + clientX: rect.left + 10, + clientY: rect.top + 10, + bubbles: true + }); + + rippleDirective.onMouseDown(mouseEvent); + + const backgroundStyleCall = setStyleSpy.calls.all().find(call => + call.args[1] === 'background' && call.args[2] === 'red' + ); + expect(backgroundStyleCall).toBeTruthy(); + }); + + it('Should center ripple when igxRippleCentered is true', () => { + const fixture = TestBed.createComponent(RippleCenteredComponent); + fixture.detectChanges(); + + const buttonDebug = fixture.debugElement.query(By.css('button')); + const rippleDirective = buttonDebug.injector.get(IgxRippleDirective); + const setStyleSpy = spyOn(rippleDirective['renderer'], 'setStyle').and.callThrough(); + const button = buttonDebug.nativeElement; + const rect = button.getBoundingClientRect(); + const mouseEvent = new MouseEvent('mousedown', { + clientX: rect.left + 50, + clientY: rect.top + 50, + bubbles: true + }); + + rippleDirective.onMouseDown(mouseEvent); + + const topStyleCall = setStyleSpy.calls.all().find(call => + call.args[1] === 'top' && call.args[2] === '0px' + ); + const leftStyleCall = setStyleSpy.calls.all().find(call => + call.args[1] === 'left' && call.args[2] === '0px' + ); + + expect(topStyleCall).toBeTruthy(); + expect(leftStyleCall).toBeTruthy(); + }); + + it('Should apply ripple to target element when igxRippleTarget is specified', () => { + const fixture = TestBed.createComponent(RippleTargetComponent); + fixture.detectChanges(); + + const containerDebug = fixture.debugElement.query(By.css('.container')); + const rippleDirective = containerDebug.injector.get(IgxRippleDirective); + const targetButton = fixture.debugElement.query(By.css('#target')).nativeElement; + const appendChildSpy = spyOn(rippleDirective['renderer'], 'appendChild').and.callThrough(); + const container = containerDebug.nativeElement; + const rect = container.getBoundingClientRect(); + const mouseEvent = new MouseEvent('mousedown', { + clientX: rect.left + 10, + clientY: rect.top + 10, + bubbles: true + }); + rippleDirective.onMouseDown(mouseEvent); + + const appendCall = appendChildSpy.calls.mostRecent(); + expect(appendCall.args[0]).toBe(targetButton); + }); + + it('Should set all required ripple element styles including position absolute', () => { + const fixture = TestBed.createComponent(RippleButtonComponent); + fixture.detectChanges(); + + const buttonDebug = fixture.debugElement.query(By.css('button')); + const button = buttonDebug.nativeElement; + const rippleDirective = buttonDebug.injector.get(IgxRippleDirective); + + button.style.width = '100px'; + button.style.height = '50px'; + + const setStyleSpy = spyOn(rippleDirective['renderer'], 'setStyle').and.callThrough(); + const rect = button.getBoundingClientRect(); + const mouseEvent = new MouseEvent('mousedown', { + clientX: rect.left + 25, + clientY: rect.top + 25, + bubbles: true + }); + rippleDirective.onMouseDown(mouseEvent); + + const styleCalls = setStyleSpy.calls.all(); + const styles = styleCalls.map(call => call.args[1]); + + expect(styles).toContain('position'); + expect(styles).toContain('width'); + expect(styles).toContain('height'); + expect(styles).toContain('top'); + expect(styles).toContain('left'); + + const positionCall = styleCalls.find(call => call.args[1] === 'position'); + expect(positionCall.args[2]).toBe('absolute'); + }); +}); + +@Component({ + template: ``, + imports: [IgxButtonDirective, IgxRippleDirective], + standalone: true +}) +class RippleButtonComponent { + @ViewChild(IgxRippleDirective, { static: true }) + public ripple: IgxRippleDirective; +} + +@Component({ + template: ``, + imports: [IgxButtonDirective, IgxRippleDirective], + standalone: true +}) +class RippleDisabledComponent { } + +@Component({ + template: ``, + imports: [IgxButtonDirective, IgxRippleDirective], + standalone: true +}) +class RippleCenteredComponent { } + +@Component({ + template: ``, + imports: [IgxButtonDirective, IgxRippleDirective], + standalone: true +}) +class RippleColorComponent { } + +@Component({ + template: ` +
+ +
+ `, + imports: [IgxButtonDirective, IgxRippleDirective], + standalone: true +}) +class RippleTargetComponent { } diff --git a/projects/igniteui-angular/directives/src/directives/ripple/ripple.directive.ts b/projects/igniteui-angular/directives/src/directives/ripple/ripple.directive.ts index d7e0dc61fd8..51ce260c485 100644 --- a/projects/igniteui-angular/directives/src/directives/ripple/ripple.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/ripple/ripple.directive.ts @@ -1,12 +1,19 @@ -import { Directive, ElementRef, HostListener, Input, NgZone, Renderer2, booleanAttribute, inject } from '@angular/core'; -import { AnimationBuilder, style, animate } from '@angular/animations'; +import { + Directive, + ElementRef, + HostListener, + Input, + NgZone, + Renderer2, + booleanAttribute, + inject +} from '@angular/core'; @Directive({ selector: '[igxRipple]', standalone: true }) export class IgxRippleDirective { - protected builder = inject(AnimationBuilder); protected elementRef = inject(ElementRef); protected renderer = inject(Renderer2); private zone = inject(NgZone); @@ -104,12 +111,14 @@ export class IgxRippleDirective { * @hidden */ @HostListener('mousedown', ['$event']) - public onMouseDown(event) { + public onMouseDown(event: MouseEvent) { this.zone.runOutsideAngular(() => this._ripple(event)); } private setStyles(rippleElement: HTMLElement, styleParams: any) { this.renderer.addClass(rippleElement, this.rippleElementClass); + // Set position absolute inline to ensure layout stability even when ripple CSS is excluded + this.renderer.setStyle(rippleElement, 'position', 'absolute'); this.renderer.setStyle(rippleElement, 'width', `${styleParams.radius}px`); this.renderer.setStyle(rippleElement, 'height', `${styleParams.radius}px`); this.renderer.setStyle(rippleElement, 'top', `${styleParams.top}px`); @@ -119,7 +128,7 @@ export class IgxRippleDirective { } } - private _ripple(event) { + private _ripple(event: MouseEvent) { if (this.rippleDisabled) { return; } @@ -147,22 +156,25 @@ export class IgxRippleDirective { this.renderer.addClass(target, this.rippleHostClass); this.renderer.appendChild(target, rippleElement); - const animation = this.builder.build([ - style({ opacity: 0.5, transform: 'scale(.3)' }), - animate(this.rippleDuration, style({ opacity: 0, transform: 'scale(2)' })) - ]).create(rippleElement); + const animation = rippleElement.animate( + [ + { opacity: 0.5, transform: 'scale(.3)' }, + { opacity: 0, transform: 'scale(2)' } + ], + { + duration: this.rippleDuration, + easing: 'ease-out' + } + ); this.animationQueue.push(animation); - animation.onDone(() => { + animation.onfinish = () => { this.animationQueue.splice(this.animationQueue.indexOf(animation), 1); target.removeChild(rippleElement); if (this.animationQueue.length < 1) { this.renderer.removeClass(target, this.rippleHostClass); } - }); - - animation.play(); - + }; } }