Skip to content

Commit 8ec949f

Browse files
authored
fix(ripple): prevent layout break when ripple styles excluded and migrate to Web Animations API (#16792)
Fixes #16759 Problem: - igxRipple directive caused host element layout breaks when ripple CSS was excluded from theme - Ripple element relied on CSS (.igx-ripple__inner) for position: absolute - Without the CSS, ripple became a regular block element, causing parent to expand - AnimationBuilder and @angular/animations APIs deprecated in v20.2, scheduled for removal in v23 Solution: 1. Added position: absolute as inline style to ensure layout stability regardless of theme configuration 2. Migrated from deprecated AnimationBuilder to native Web Animations API - Removed dependency on @angular/animations package - Replaced builder.build().create() with element.animate() - Updated animation lifecycle handling (onDone -> onfinish) 3. Created comprehensive test suite (previously missing) with 8 test cases including: - Layout stability verification without CSS - Inline style application checks - Disabled, centered, and targeted ripple scenarios - Custom color support Benefits: - Fixes layout issue when ripple styles excluded from production builds - Future-proof: removes usage of deprecated Angular animations - Better performance: hardware-accelerated native Web Animations API - Smaller bundle: no @angular/animations dependency needed - Comprehensive test coverage for ripple directive BREAKING CHANGES: None - all existing ripple functionality and API remain unchanged
1 parent bae85ea commit 8ec949f

2 files changed

Lines changed: 288 additions & 14 deletions

File tree

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { Component, ViewChild } from '@angular/core';
2+
import { TestBed, waitForAsync } from '@angular/core/testing';
3+
import { By } from '@angular/platform-browser';
4+
import { IgxRippleDirective } from './ripple.directive';
5+
import { IgxButtonDirective } from '../button/button.directive';
6+
7+
describe('IgxRipple', () => {
8+
beforeEach(waitForAsync(() => {
9+
TestBed.configureTestingModule({
10+
imports: [
11+
RippleButtonComponent,
12+
RippleDisabledComponent,
13+
RippleCenteredComponent,
14+
RippleColorComponent,
15+
RippleTargetComponent
16+
]
17+
}).compileComponents();
18+
}));
19+
20+
it('Should initialize ripple directive on button', () => {
21+
const fixture = TestBed.createComponent(RippleButtonComponent);
22+
fixture.detectChanges();
23+
24+
const button = fixture.debugElement.query(By.css('button'));
25+
const rippleDirective = button.injector.get(IgxRippleDirective);
26+
27+
expect(rippleDirective).toBeTruthy();
28+
expect(button.nativeElement).toBeTruthy();
29+
});
30+
31+
it('Should not affect host element size when ripple is triggered without CSS styles', () => {
32+
const fixture = TestBed.createComponent(RippleButtonComponent);
33+
fixture.detectChanges();
34+
35+
const buttonDebug = fixture.debugElement.query(By.css('button'));
36+
const button = buttonDebug.nativeElement;
37+
const rippleDirective = buttonDebug.injector.get(IgxRippleDirective);
38+
39+
// Set explicit dimensions to ensure we can measure them
40+
button.style.width = '100px';
41+
button.style.height = '40px';
42+
button.style.padding = '10px';
43+
fixture.detectChanges();
44+
45+
const initialWidth = button.offsetWidth;
46+
const initialHeight = button.offsetHeight;
47+
48+
expect(initialWidth).toBeGreaterThan(0);
49+
expect(initialHeight).toBeGreaterThan(0);
50+
51+
const setStylesSpy = spyOn<any>(rippleDirective, 'setStyles').and.callThrough();
52+
const rect = button.getBoundingClientRect();
53+
const mouseEvent = new MouseEvent('mousedown', {
54+
clientX: rect.left + 50,
55+
clientY: rect.top + 20,
56+
bubbles: true
57+
});
58+
59+
rippleDirective.onMouseDown(mouseEvent);
60+
61+
expect(setStylesSpy).toHaveBeenCalled();
62+
63+
const rippleElement = setStylesSpy.calls.mostRecent().args[0] as HTMLElement;
64+
65+
expect(rippleElement.style.position).toBe('absolute');
66+
67+
const afterWidth = button.offsetWidth;
68+
const afterHeight = button.offsetHeight;
69+
70+
expect(afterWidth).toBe(initialWidth);
71+
expect(afterHeight).toBe(initialHeight);
72+
});
73+
74+
it('Should create ripple element with position absolute style', () => {
75+
const fixture = TestBed.createComponent(RippleButtonComponent);
76+
fixture.detectChanges();
77+
78+
const buttonDebug = fixture.debugElement.query(By.css('button'));
79+
const rippleDirective = buttonDebug.injector.get(IgxRippleDirective);
80+
const setStyleSpy = spyOn(rippleDirective['renderer'], 'setStyle').and.callThrough();
81+
const button = buttonDebug.nativeElement;
82+
const rect = button.getBoundingClientRect();
83+
const mouseEvent = new MouseEvent('mousedown', {
84+
clientX: rect.left + 10,
85+
clientY: rect.top + 10,
86+
bubbles: true
87+
});
88+
89+
rippleDirective.onMouseDown(mouseEvent);
90+
91+
const positionStyleCall = setStyleSpy.calls.all().find(call =>
92+
call.args[1] === 'position' && call.args[2] === 'absolute'
93+
);
94+
expect(positionStyleCall).toBeTruthy();
95+
});
96+
97+
it('Should not create ripple when disabled', () => {
98+
const fixture = TestBed.createComponent(RippleDisabledComponent);
99+
fixture.detectChanges();
100+
101+
const buttonDebug = fixture.debugElement.query(By.css('button'));
102+
const rippleDirective = buttonDebug.injector.get(IgxRippleDirective);
103+
const setStylesSpy = spyOn<any>(rippleDirective, 'setStyles');
104+
const button = buttonDebug.nativeElement;
105+
const rect = button.getBoundingClientRect();
106+
const mouseEvent = new MouseEvent('mousedown', {
107+
clientX: rect.left + 10,
108+
clientY: rect.top + 10,
109+
bubbles: true
110+
});
111+
112+
rippleDirective.onMouseDown(mouseEvent);
113+
expect(setStylesSpy).not.toHaveBeenCalled();
114+
});
115+
116+
it('Should apply custom ripple color as background style', () => {
117+
const fixture = TestBed.createComponent(RippleColorComponent);
118+
fixture.detectChanges();
119+
120+
const buttonDebug = fixture.debugElement.query(By.css('button'));
121+
const rippleDirective = buttonDebug.injector.get(IgxRippleDirective);
122+
const setStyleSpy = spyOn(rippleDirective['renderer'], 'setStyle').and.callThrough();
123+
const button = buttonDebug.nativeElement;
124+
const rect = button.getBoundingClientRect();
125+
const mouseEvent = new MouseEvent('mousedown', {
126+
clientX: rect.left + 10,
127+
clientY: rect.top + 10,
128+
bubbles: true
129+
});
130+
131+
rippleDirective.onMouseDown(mouseEvent);
132+
133+
const backgroundStyleCall = setStyleSpy.calls.all().find(call =>
134+
call.args[1] === 'background' && call.args[2] === 'red'
135+
);
136+
expect(backgroundStyleCall).toBeTruthy();
137+
});
138+
139+
it('Should center ripple when igxRippleCentered is true', () => {
140+
const fixture = TestBed.createComponent(RippleCenteredComponent);
141+
fixture.detectChanges();
142+
143+
const buttonDebug = fixture.debugElement.query(By.css('button'));
144+
const rippleDirective = buttonDebug.injector.get(IgxRippleDirective);
145+
const setStyleSpy = spyOn(rippleDirective['renderer'], 'setStyle').and.callThrough();
146+
const button = buttonDebug.nativeElement;
147+
const rect = button.getBoundingClientRect();
148+
const mouseEvent = new MouseEvent('mousedown', {
149+
clientX: rect.left + 50,
150+
clientY: rect.top + 50,
151+
bubbles: true
152+
});
153+
154+
rippleDirective.onMouseDown(mouseEvent);
155+
156+
const topStyleCall = setStyleSpy.calls.all().find(call =>
157+
call.args[1] === 'top' && call.args[2] === '0px'
158+
);
159+
const leftStyleCall = setStyleSpy.calls.all().find(call =>
160+
call.args[1] === 'left' && call.args[2] === '0px'
161+
);
162+
163+
expect(topStyleCall).toBeTruthy();
164+
expect(leftStyleCall).toBeTruthy();
165+
});
166+
167+
it('Should apply ripple to target element when igxRippleTarget is specified', () => {
168+
const fixture = TestBed.createComponent(RippleTargetComponent);
169+
fixture.detectChanges();
170+
171+
const containerDebug = fixture.debugElement.query(By.css('.container'));
172+
const rippleDirective = containerDebug.injector.get(IgxRippleDirective);
173+
const targetButton = fixture.debugElement.query(By.css('#target')).nativeElement;
174+
const appendChildSpy = spyOn(rippleDirective['renderer'], 'appendChild').and.callThrough();
175+
const container = containerDebug.nativeElement;
176+
const rect = container.getBoundingClientRect();
177+
const mouseEvent = new MouseEvent('mousedown', {
178+
clientX: rect.left + 10,
179+
clientY: rect.top + 10,
180+
bubbles: true
181+
});
182+
rippleDirective.onMouseDown(mouseEvent);
183+
184+
const appendCall = appendChildSpy.calls.mostRecent();
185+
expect(appendCall.args[0]).toBe(targetButton);
186+
});
187+
188+
it('Should set all required ripple element styles including position absolute', () => {
189+
const fixture = TestBed.createComponent(RippleButtonComponent);
190+
fixture.detectChanges();
191+
192+
const buttonDebug = fixture.debugElement.query(By.css('button'));
193+
const button = buttonDebug.nativeElement;
194+
const rippleDirective = buttonDebug.injector.get(IgxRippleDirective);
195+
196+
button.style.width = '100px';
197+
button.style.height = '50px';
198+
199+
const setStyleSpy = spyOn(rippleDirective['renderer'], 'setStyle').and.callThrough();
200+
const rect = button.getBoundingClientRect();
201+
const mouseEvent = new MouseEvent('mousedown', {
202+
clientX: rect.left + 25,
203+
clientY: rect.top + 25,
204+
bubbles: true
205+
});
206+
rippleDirective.onMouseDown(mouseEvent);
207+
208+
const styleCalls = setStyleSpy.calls.all();
209+
const styles = styleCalls.map(call => call.args[1]);
210+
211+
expect(styles).toContain('position');
212+
expect(styles).toContain('width');
213+
expect(styles).toContain('height');
214+
expect(styles).toContain('top');
215+
expect(styles).toContain('left');
216+
217+
const positionCall = styleCalls.find(call => call.args[1] === 'position');
218+
expect(positionCall.args[2]).toBe('absolute');
219+
});
220+
});
221+
222+
@Component({
223+
template: `<button igxButton igxRipple>Test Button</button>`,
224+
imports: [IgxButtonDirective, IgxRippleDirective],
225+
standalone: true
226+
})
227+
class RippleButtonComponent {
228+
@ViewChild(IgxRippleDirective, { static: true })
229+
public ripple: IgxRippleDirective;
230+
}
231+
232+
@Component({
233+
template: `<button igxButton igxRipple [igxRippleDisabled]="true">Disabled Ripple</button>`,
234+
imports: [IgxButtonDirective, IgxRippleDirective],
235+
standalone: true
236+
})
237+
class RippleDisabledComponent { }
238+
239+
@Component({
240+
template: `<button igxButton igxRipple [igxRippleCentered]="true">Centered Ripple</button>`,
241+
imports: [IgxButtonDirective, IgxRippleDirective],
242+
standalone: true
243+
})
244+
class RippleCenteredComponent { }
245+
246+
@Component({
247+
template: `<button igxButton [igxRipple]="'red'">Colored Ripple</button>`,
248+
imports: [IgxButtonDirective, IgxRippleDirective],
249+
standalone: true
250+
})
251+
class RippleColorComponent { }
252+
253+
@Component({
254+
template: `
255+
<div class="container" igxRipple [igxRippleTarget]="'#target'">
256+
<button id="target" igxButton>Target Button</button>
257+
</div>
258+
`,
259+
imports: [IgxButtonDirective, IgxRippleDirective],
260+
standalone: true
261+
})
262+
class RippleTargetComponent { }

projects/igniteui-angular/directives/src/directives/ripple/ripple.directive.ts

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1-
import { Directive, ElementRef, HostListener, Input, NgZone, Renderer2, booleanAttribute, inject } from '@angular/core';
2-
import { AnimationBuilder, style, animate } from '@angular/animations';
1+
import {
2+
Directive,
3+
ElementRef,
4+
HostListener,
5+
Input,
6+
NgZone,
7+
Renderer2,
8+
booleanAttribute,
9+
inject
10+
} from '@angular/core';
311

412
@Directive({
513
selector: '[igxRipple]',
614
standalone: true
715
})
816
export class IgxRippleDirective {
9-
protected builder = inject(AnimationBuilder);
1017
protected elementRef = inject(ElementRef);
1118
protected renderer = inject(Renderer2);
1219
private zone = inject(NgZone);
@@ -104,12 +111,14 @@ export class IgxRippleDirective {
104111
* @hidden
105112
*/
106113
@HostListener('mousedown', ['$event'])
107-
public onMouseDown(event) {
114+
public onMouseDown(event: MouseEvent) {
108115
this.zone.runOutsideAngular(() => this._ripple(event));
109116
}
110117

111118
private setStyles(rippleElement: HTMLElement, styleParams: any) {
112119
this.renderer.addClass(rippleElement, this.rippleElementClass);
120+
// Set position absolute inline to ensure layout stability even when ripple CSS is excluded
121+
this.renderer.setStyle(rippleElement, 'position', 'absolute');
113122
this.renderer.setStyle(rippleElement, 'width', `${styleParams.radius}px`);
114123
this.renderer.setStyle(rippleElement, 'height', `${styleParams.radius}px`);
115124
this.renderer.setStyle(rippleElement, 'top', `${styleParams.top}px`);
@@ -119,7 +128,7 @@ export class IgxRippleDirective {
119128
}
120129
}
121130

122-
private _ripple(event) {
131+
private _ripple(event: MouseEvent) {
123132
if (this.rippleDisabled) {
124133
return;
125134
}
@@ -147,22 +156,25 @@ export class IgxRippleDirective {
147156
this.renderer.addClass(target, this.rippleHostClass);
148157
this.renderer.appendChild(target, rippleElement);
149158

150-
const animation = this.builder.build([
151-
style({ opacity: 0.5, transform: 'scale(.3)' }),
152-
animate(this.rippleDuration, style({ opacity: 0, transform: 'scale(2)' }))
153-
]).create(rippleElement);
159+
const animation = rippleElement.animate(
160+
[
161+
{ opacity: 0.5, transform: 'scale(.3)' },
162+
{ opacity: 0, transform: 'scale(2)' }
163+
],
164+
{
165+
duration: this.rippleDuration,
166+
easing: 'ease-out'
167+
}
168+
);
154169

155170
this.animationQueue.push(animation);
156171

157-
animation.onDone(() => {
172+
animation.onfinish = () => {
158173
this.animationQueue.splice(this.animationQueue.indexOf(animation), 1);
159174
target.removeChild(rippleElement);
160175
if (this.animationQueue.length < 1) {
161176
this.renderer.removeClass(target, this.rippleHostClass);
162177
}
163-
});
164-
165-
animation.play();
166-
178+
};
167179
}
168180
}

0 commit comments

Comments
 (0)