Skip to content

Commit

Permalink
fix(material/core): ripples persisting when container is removed from…
Browse files Browse the repository at this point in the history
… DOM while fading-in

Follow-up to 65fb5f4.

We recently changed how animations are handled in the ripple. As part of this change
we accidentally broke the select <> ripple interaction. The select is special because
the option panel is removed from the DOM as soon as an option got clicked.

An option click will trigger a ripple that will usually still fade-in when the
panel is removed. Currently such ripples never will complete because the `transitionend`
event does not fire once removed from DOM. We should destroy the ripples when the
transition is canceled (e.g. through DOM removal).
  • Loading branch information
devversion committed Feb 25, 2022
1 parent 8a12da7 commit e6bd320
Show file tree
Hide file tree
Showing 11 changed files with 138 additions and 2 deletions.
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@
/src/e2e-app/progress-bar/** @andrewseguin @crisbeto
/src/e2e-app/progress-spinner/** @andrewseguin @crisbeto
/src/e2e-app/radio/** @andrewseguin @devversion
/src/e2e-app/select/** @crisbeto
/src/e2e-app/sidenav/** @mmalerba
/src/e2e-app/slide-toggle/** @devversion
/src/e2e-app/stepper/** @mmalerba
Expand Down
1 change: 1 addition & 0 deletions src/e2e-app/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ ng_module(
"//src/material/progress-bar",
"//src/material/progress-spinner",
"//src/material/radio",
"//src/material/select",
"//src/material/sidenav",
"//src/material/slide-toggle",
"//src/material/tabs",
Expand Down
1 change: 1 addition & 0 deletions src/e2e-app/e2e-app/e2e-app-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class E2eAppLayout {
{path: 'progress-bar', title: 'Progress bar'},
{path: 'progress-spinner', title: 'Progress Spinner'},
{path: 'radio', title: 'Radios'},
{path: 'select', title: 'Select'},
{path: 'sidenav', title: 'Sidenav'},
{path: 'slide-toggle', title: 'Slide Toggle'},
{path: 'stepper', title: 'Stepper'},
Expand Down
7 changes: 5 additions & 2 deletions src/e2e-app/main-module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {RouterModule} from '@angular/router';
import {BlockScrollStrategyE2eModule} from './block-scroll-strategy/block-scroll-strategy-e2e-module';
import {ButtonToggleE2eModule} from './button-toggle/button-toggle-e2e-module';
Expand Down Expand Up @@ -41,11 +41,14 @@ import {VirtualScrollE2eModule} from './virtual-scroll/virtual-scroll-e2e-module
import {MdcProgressBarE2eModule} from './mdc-progress-bar/mdc-progress-bar-e2e-module';
import {MdcProgressSpinnerE2eModule} from './mdc-progress-spinner/mdc-progress-spinner-module';

/** We allow for animations to be explicitly enabled in certain e2e tests. */
const enableAnimations = window.location.search.includes('animations=true');

@NgModule({
imports: [
BrowserModule,
E2eAppModule,
NoopAnimationsModule,
BrowserAnimationsModule.withConfig({disableAnimations: !enableAnimations}),
RouterModule.forRoot(E2E_APP_ROUTES),

// E2E demos
Expand Down
2 changes: 2 additions & 0 deletions src/e2e-app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {BasicTabs} from './tabs/tabs-e2e';
import {ToolbarE2e} from './toolbar/toolbar-e2e';
import {VirtualScrollE2E} from './virtual-scroll/virtual-scroll-e2e';
import {Home} from './e2e-app/e2e-app-layout';
import {SelectE2e} from './select/select-e2e';

export const E2E_APP_ROUTES: Routes = [
{path: '', component: Home},
Expand Down Expand Up @@ -70,6 +71,7 @@ export const E2E_APP_ROUTES: Routes = [
{path: 'progress-spinner', component: ProgressSpinnerE2E},
{path: 'radio', component: SimpleRadioButtons},
{path: 'sidenav', component: SidenavE2E},
{path: 'select', component: SelectE2e},
{path: 'slide-toggle', component: SlideToggleE2E},
{path: 'stepper', component: StepperE2e},
{path: 'tabs', component: BasicTabs},
Expand Down
17 changes: 17 additions & 0 deletions src/e2e-app/select/select-e2e-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {NgModule} from '@angular/core';
import {ExampleViewerModule} from '../example-viewer/example-viewer-module';
import {SelectE2e} from './select-e2e';

@NgModule({
imports: [ExampleViewerModule],
declarations: [SelectE2e],
})
export class SelectE2eModule {}
17 changes: 17 additions & 0 deletions src/e2e-app/select/select-e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {Component} from '@angular/core';

@Component({
selector: 'select-demo',
template: `<example-list-viewer [ids]="examples"></example-list-viewer>`,
})
export class SelectE2e {
examples = ['select-overview'];
}
4 changes: 4 additions & 0 deletions src/material/core/ripple/ripple-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ export class RippleRenderer implements EventListenerObject {
if (!animationForciblyDisabledThroughCss && (enterDuration || animationConfig.exitDuration)) {
this._ngZone.runOutsideAngular(() => {
ripple.addEventListener('transitionend', () => this._finishRippleTransition(rippleRef));
// If the transition is cancelled (e.g. due to DOM removal), we destroy the ripple
// directly as otherwise we would keep it part of the ripple container forever.
// https://www.w3.org/TR/css-transitions-1/#:~:text=no%20longer%20in%20the%20document.
ripple.addEventListener('transitioncancel', () => this._destroyRipple(rippleRef));
});
}

Expand Down
39 changes: 39 additions & 0 deletions src/material/core/ripple/ripple.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ describe('MatRipple', () => {
RippleContainerWithNgIf,
RippleCssTransitionNone,
RippleCssTransitionDurationZero,
RippleWithDomRemovalOnClick,
],
});
});
Expand Down Expand Up @@ -802,6 +803,33 @@ describe('MatRipple', () => {
dispatchMouseEvent(rippleTarget, 'mouseup');
expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
});

it('should destroy the ripple if the transition is being canceled due to DOM removal', async () => {
fixture = TestBed.createComponent(RippleWithDomRemovalOnClick);
fixture.detectChanges();

rippleTarget = fixture.nativeElement.querySelector('.mat-ripple');

dispatchMouseEvent(rippleTarget, 'mousedown');
dispatchMouseEvent(rippleTarget, 'mouseup');
dispatchMouseEvent(rippleTarget, 'click');

const fadingRipple = rippleTarget.querySelector('.mat-ripple-element');
expect(fadingRipple).not.toBe(null);

// The ripple animation is still on-going but the element is now removed from DOM as
// part of the change detecton (given `show` being set to `false` on click)
fixture.detectChanges();

// The `transitioncancel` event is emitted when a CSS transition is canceled due
// to e.g. DOM removal. More details in the CSS transitions spec:
// https://www.w3.org/TR/css-transitions-1/#:~:text=no%20longer%20in%20the%20document.
dispatchFakeEvent(fadingRipple!, 'transitioncancel');

// There should be no ripple element anymore because the fading-in ripple from
// before had its animation canceled due the DOM removal.
expect(rippleTarget.querySelector('.mat-ripple-element')).toBeNull();
});
});
});

Expand Down Expand Up @@ -866,3 +894,14 @@ class RippleCssTransitionNone {}
encapsulation: ViewEncapsulation.None,
})
class RippleCssTransitionDurationZero {}

@Component({
template: `
<div *ngIf="show" (click)="show = false" matRipple>
Click to remove this element.
</div>
`,
})
class RippleWithDomRemovalOnClick {
show = true;
}
15 changes: 15 additions & 0 deletions src/material/select/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
load("//src/e2e-app:test_suite.bzl", "e2e_test_suite")
load(
"//tools:defaults.bzl",
"markdown_to_html",
"ng_e2e_test_library",
"ng_module",
"ng_test_library",
"ng_web_test_suite",
Expand Down Expand Up @@ -78,6 +80,19 @@ ng_web_test_suite(
deps = [":unit_test_sources"],
)

ng_e2e_test_library(
name = "e2e_test_sources",
srcs = glob(["**/*.e2e.spec.ts"]),
deps = [
"//src/cdk/testing/private/e2e",
],
)

e2e_test_suite(
name = "e2e_tests",
deps = [":e2e_test_sources"],
)

markdown_to_html(
name = "overview",
srcs = [":select.md"],
Expand Down
36 changes: 36 additions & 0 deletions src/material/select/select.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {browser, by, element, ExpectedConditions} from 'protractor';
import {getElement} from '../../cdk/testing/private/e2e';

const presenceOf = ExpectedConditions.presenceOf;
const not = ExpectedConditions.not;

describe('select', () => {
beforeEach(async () => await browser.get('/select?animations=true'));

// Regression test which ensures that ripples within the select are not persisted
// accidentally. This could happen because the select panel is removed from DOM
// immediately when an option is clicked. Usually ripples still fade-in at that point.
it('should not accidentally persist ripples', async () => {
const select = getElement('.mat-select');
const options = element.all(by.css('.mat-option'));
const ripples = element.all(by.css('.mat-ripple-element'));

// Wait for select to be rendered.
await browser.wait(presenceOf(select));

// Opent the select and wait for options to be displayed.
await select.click();
await browser.wait(presenceOf(options.get(0)));

// Click the first option and wait for the select to be closed.
await options.get(0).click();
await browser.wait(not(presenceOf(options.get(0))));

// Re-open the select and wait for it to be rendered.
await select.click();
await browser.wait(presenceOf(options.get(0)));

// Expect no ripples to be showing up without an option click.
expect(await ripples.isPresent()).toBe(false);
});
});

0 comments on commit e6bd320

Please sign in to comment.