Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<any>(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<any>(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: `<button igxButton igxRipple>Test Button</button>`,
imports: [IgxButtonDirective, IgxRippleDirective],
standalone: true
})
class RippleButtonComponent {
@ViewChild(IgxRippleDirective, { static: true })
public ripple: IgxRippleDirective;
}

@Component({
template: `<button igxButton igxRipple [igxRippleDisabled]="true">Disabled Ripple</button>`,
imports: [IgxButtonDirective, IgxRippleDirective],
standalone: true
})
class RippleDisabledComponent { }

@Component({
template: `<button igxButton igxRipple [igxRippleCentered]="true">Centered Ripple</button>`,
imports: [IgxButtonDirective, IgxRippleDirective],
standalone: true
})
class RippleCenteredComponent { }

@Component({
template: `<button igxButton [igxRipple]="'red'">Colored Ripple</button>`,
imports: [IgxButtonDirective, IgxRippleDirective],
standalone: true
})
class RippleColorComponent { }

@Component({
template: `
<div class="container" igxRipple [igxRippleTarget]="'#target'">
<button id="target" igxButton>Target Button</button>
</div>
`,
imports: [IgxButtonDirective, IgxRippleDirective],
standalone: true
})
class RippleTargetComponent { }
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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`);
Expand All @@ -119,7 +128,7 @@ export class IgxRippleDirective {
}
}

private _ripple(event) {
private _ripple(event: MouseEvent) {
if (this.rippleDisabled) {
return;
}
Expand Down Expand Up @@ -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();

};
}
}
Loading