import {Platform} from '@angular/cdk/platform';
import {
  createMouseEvent,
  createTouchEvent,
  dispatchEvent,
  dispatchFakeEvent,
  dispatchMouseEvent,
  dispatchTouchEvent,
} from '@angular/cdk/testing/private';
import {Component, ViewChild, ViewEncapsulation} from '@angular/core';
import {ComponentFixture, TestBed, inject} from '@angular/core/testing';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {
  MAT_RIPPLE_GLOBAL_OPTIONS,
  MatRipple,
  MatRippleModule,
  RippleAnimationConfig,
  RippleGlobalOptions,
  RippleState,
} from './index';

describe('MatRipple', () => {
  let fixture: ComponentFixture<any>;
  let rippleTarget: HTMLElement;
  let originalBodyMargin: string | null;
  let platform: Platform;

  /** Extracts the numeric value of a pixel size string like '123px'. */
  const pxStringToFloat = (s: string | null) => (s ? parseFloat(s) : 0);
  const startingWindowWidth = window.innerWidth;
  const startingWindowHeight = window.innerHeight;

  /** Flushes the transition of the ripple element inside of the ripple target. */
  function flushTransition() {
    dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');
  }

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        MatRippleModule,
        BasicRippleContainer,
        RippleContainerWithInputBindings,
        RippleContainerWithoutBindings,
        RippleContainerWithNgIf,
        RippleCssTransitionNone,
        RippleCssTransitionDurationZero,
        RippleWithDomRemovalOnClick,
      ],
    });
  });

  beforeEach(inject([Platform], (p: Platform) => {
    platform = p;

    // Set body margin to 0 during tests so it doesn't mess up position calculations.
    originalBodyMargin = document.body.style.margin;
    document.body.style.margin = '0';
  }));

  afterEach(() => {
    document.body.style.margin = originalBodyMargin!;
  });

  describe('basic ripple', () => {
    let rippleDirective: MatRipple;

    const TARGET_HEIGHT = 200;
    const TARGET_WIDTH = 300;

    beforeEach(() => {
      fixture = TestBed.createComponent(BasicRippleContainer);
      fixture.detectChanges();

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

    it('sizes ripple to cover element', () => {
      // This test is consistently flaky on iOS (vs. Safari on desktop and all other browsers).
      // Temporarily skip this test on iOS until we can determine the source of the flakiness.
      // TODO(jelbourn): determine the source of flakiness here
      if (platform.IOS) {
        return;
      }

      let elementRect = rippleTarget.getBoundingClientRect();

      // Dispatch a ripple at the following relative coordinates (X: 50| Y: 75)
      dispatchMouseEvent(rippleTarget, 'mousedown', 50, 75);
      dispatchMouseEvent(rippleTarget, 'mouseup');

      // Calculate distance from the click to farthest edge of the ripple target.
      let maxDistanceX = TARGET_WIDTH - 50;
      let maxDistanceY = TARGET_HEIGHT - 75;

      // At this point the foreground ripple should be created with a div centered at the click
      // location, and large enough to reach the furthest corner, which is 250px to the right
      // and 125px down relative to the click position.
      let expectedRadius = Math.sqrt(maxDistanceX * maxDistanceX + maxDistanceY * maxDistanceY);
      let expectedLeft = elementRect.left + 50 - expectedRadius;
      let expectedTop = elementRect.top + 75 - expectedRadius;

      let ripple = rippleTarget.querySelector('.mat-ripple-element') as HTMLElement;

      // Note: getBoundingClientRect won't work because there's a transform applied to make the
      // ripple start out tiny.
      expect(pxStringToFloat(ripple.style.left)).toBeCloseTo(expectedLeft, 1);
      expect(pxStringToFloat(ripple.style.top)).toBeCloseTo(expectedTop, 1);
      expect(pxStringToFloat(ripple.style.width)).toBeCloseTo(2 * expectedRadius, 1);
      expect(pxStringToFloat(ripple.style.height)).toBeCloseTo(2 * expectedRadius, 1);
    });

    it('creates ripple on mousedown', () => {
      dispatchMouseEvent(rippleTarget, 'mousedown');
      dispatchMouseEvent(rippleTarget, 'mouseup');

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

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

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(2);
    });

    it('should launch ripples on touchstart', () => {
      dispatchTouchEvent(rippleTarget, 'touchstart');
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      dispatchTouchEvent(rippleTarget, 'touchend');
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('should clear ripples if the touch sequence is cancelled', () => {
      dispatchTouchEvent(rippleTarget, 'touchstart');
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      dispatchTouchEvent(rippleTarget, 'touchcancel');
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('should launch multiple ripples for multi-touch', () => {
      const touchEvent = createTouchEvent('touchstart');

      Object.defineProperties(touchEvent, {
        changedTouches: {
          value: [
            {pageX: 0, pageY: 0},
            {pageX: 10, pageY: 10},
            {pageX: 20, pageY: 20},
          ],
        },
      });

      dispatchEvent(rippleTarget, touchEvent);

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(3);

      const rippleElements = rippleTarget.querySelectorAll('.mat-ripple-element');

      // Flush the fade-in transition of all three ripples.
      dispatchFakeEvent(rippleElements[0], 'transitionend');
      dispatchFakeEvent(rippleElements[1], 'transitionend');
      dispatchFakeEvent(rippleElements[2], 'transitionend');

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(3);

      dispatchTouchEvent(rippleTarget, 'touchend');

      // Flush the fade-out transition of all three ripples.
      dispatchFakeEvent(rippleElements[0], 'transitionend');
      dispatchFakeEvent(rippleElements[1], 'transitionend');
      dispatchFakeEvent(rippleElements[2], 'transitionend');

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('should ignore synthetic mouse events after touchstart', () => {
      dispatchTouchEvent(rippleTarget, 'touchstart');
      dispatchTouchEvent(rippleTarget, 'mousedown');

      flushTransition();
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      dispatchTouchEvent(rippleTarget, 'touchend');

      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('should ignore fake mouse events from screen readers', () => {
      const event = createMouseEvent('mousedown');
      Object.defineProperties(event, {buttons: {get: () => 0}, detail: {get: () => 0}});

      dispatchEvent(rippleTarget, event);

      expect(rippleTarget.querySelector('.mat-ripple-element')).toBeFalsy();
    });

    it('removes ripple after timeout', () => {
      dispatchMouseEvent(rippleTarget, 'mousedown');
      dispatchMouseEvent(rippleTarget, 'mouseup');

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      // Flush fade-in and fade-out transition.
      flushTransition();
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('should remove ripples after mouseup', () => {
      dispatchMouseEvent(rippleTarget, 'mousedown');

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      // Flush the transition of fading in. Also flush the potential fading-out transition in
      // order to make sure that the ripples didn't fade-out before mouseup.
      flushTransition();
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      dispatchMouseEvent(rippleTarget, 'mouseup');
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('creates ripples when manually triggered', () => {
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);

      rippleDirective.launch(0, 0);

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);
    });

    it('creates manual ripples with the default ripple config', () => {
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);

      // Calculate the diagonal distance and divide it by two for the center radius.
      let radius = Math.sqrt(TARGET_HEIGHT * TARGET_HEIGHT + TARGET_WIDTH * TARGET_WIDTH) / 2;

      rippleDirective.centered = true;
      fixture.changeDetectorRef.markForCheck();
      rippleDirective.launch(0, 0);

      let rippleElement = rippleTarget.querySelector('.mat-ripple-element') as HTMLElement;

      expect(rippleElement).toBeTruthy();
      expect(parseFloat(rippleElement.style.left as string)).toBeCloseTo(
        TARGET_WIDTH / 2 - radius,
        1,
      );
      expect(parseFloat(rippleElement.style.top as string)).toBeCloseTo(
        TARGET_HEIGHT / 2 - radius,
        1,
      );
    });

    it('cleans up the event handlers when the container gets destroyed', () => {
      fixture = TestBed.createComponent(RippleContainerWithNgIf);
      fixture.detectChanges();

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

      fixture.componentInstance.isDestroyed = true;
      fixture.changeDetectorRef.markForCheck();
      fixture.detectChanges();

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

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('should only persist the latest ripple on pointer down', () => {
      dispatchMouseEvent(rippleTarget, 'mousedown');
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      dispatchMouseEvent(rippleTarget, 'mousedown');
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(2);

      // Flush the fade-in transition.
      flushTransition();
      // Flush the fade-out transition.
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);
    });

    describe('when page is scrolled', () => {
      let veryLargeElement: HTMLDivElement = document.createElement('div');
      let pageScrollTop = 500;
      let pageScrollLeft = 500;

      beforeEach(() => {
        // Add a very large element to make the page scroll
        veryLargeElement.style.width = '4000px';
        veryLargeElement.style.height = '4000px';

        document.body.appendChild(veryLargeElement);
        document.body.scrollTop = pageScrollTop;
        document.body.scrollLeft = pageScrollLeft;

        // Firefox
        document.documentElement!.scrollLeft = pageScrollLeft;
        document.documentElement!.scrollTop = pageScrollTop;

        // Mobile safari
        window.scrollTo(pageScrollLeft, pageScrollTop);
      });

      afterEach(() => {
        veryLargeElement.remove();
        document.body.scrollTop = 0;
        document.body.scrollLeft = 0;

        // Firefox
        document.documentElement!.scrollLeft = 0;
        document.documentElement!.scrollTop = 0;

        // Mobile safari
        window.scrollTo(0, 0);
      });

      it('create ripple with correct position', () => {
        let elementTop = 600;
        let elementLeft = 750;
        let left = 50;
        let top = 75;

        rippleTarget.style.left = `${elementLeft}px`;
        rippleTarget.style.top = `${elementTop}px`;

        // Simulate a keyboard-triggered click by setting event coordinates to 0.
        dispatchMouseEvent(
          rippleTarget,
          'mousedown',
          left + elementLeft - pageScrollLeft,
          top + elementTop - pageScrollTop,
        );

        let expectedRadius = Math.sqrt(250 * 250 + 125 * 125);
        let expectedLeft = left - expectedRadius;
        let expectedTop = top - expectedRadius;

        let ripple = rippleTarget.querySelector('.mat-ripple-element') as HTMLElement;

        // In the iOS simulator (BrowserStack & SauceLabs), adding the content to the
        // body causes karma's iframe for the test to stretch to fit that content once we attempt to
        // scroll the page. Setting width / height / maxWidth / maxHeight on the iframe does not
        // successfully constrain its size. As such, skip assertions in environments where the
        // window size has changed since the start of the test.
        if (window.innerWidth > startingWindowWidth || window.innerHeight > startingWindowHeight) {
          return;
        }

        expect(pxStringToFloat(ripple.style.left)).toBeCloseTo(expectedLeft, 1);
        expect(pxStringToFloat(ripple.style.top)).toBeCloseTo(expectedTop, 1);
        expect(pxStringToFloat(ripple.style.width)).toBeCloseTo(2 * expectedRadius, 1);
        expect(pxStringToFloat(ripple.style.height)).toBeCloseTo(2 * expectedRadius, 1);
      });
    });
  });

  describe('manual ripples', () => {
    let rippleDirective: MatRipple;

    beforeEach(() => {
      fixture = TestBed.createComponent(BasicRippleContainer);
      fixture.detectChanges();

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

    it('should allow persistent ripple elements', () => {
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);

      let rippleRef = rippleDirective.launch(0, 0, {persistent: true});

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      // Flush the fade-in transition. Additionally flush the potential fade-out transition
      // in order to make sure that the ripple is persistent and won't fade-out.
      flushTransition();
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      rippleRef.fadeOut();
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('should remove ripples that are not done fading in', () => {
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);

      rippleDirective.launch(0, 0);

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      // The ripple should still fade in right now. Now by calling `fadeOutAll` the ripple should
      // immediately start fading out. We can verify this by just flushing the current transition
      // and verifying if the ripple has been removed from the DOM.
      rippleDirective.fadeOutAll();
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
        .withContext('Expected no ripples to be active after calling fadeOutAll.')
        .toBe(0);
    });

    it('should properly set ripple states', () => {
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);

      let rippleRef = rippleDirective.launch(0, 0, {persistent: true});

      expect(rippleRef.state).toBe(RippleState.FADING_IN);
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      flushTransition();

      expect(rippleRef.state).toBe(RippleState.VISIBLE);
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      rippleRef.fadeOut();

      expect(rippleRef.state).toBe(RippleState.FADING_OUT);
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      flushTransition();

      expect(rippleRef.state).toBe(RippleState.HIDDEN);
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('should allow setting a specific animation config for a ripple', () => {
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);

      const rippleRef = rippleDirective.launch(0, 0, {
        animation: {enterDuration: 120, exitDuration: 0},
      });

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      // Since we cannot use `fakeAsync`, we manually verify that the element has
      // the specified transition duration.
      expect(rippleRef.element.style.transitionDuration).toBe('120ms');

      // We still flush the 120ms transition and should check if the 0ms exit transition happened
      // properly.
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('should allow passing only a configuration', () => {
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);

      const rippleRef = rippleDirective.launch({persistent: true});

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      // Flush the fade-in transition. Additionally flush the potential fade-out transition
      // in order to make sure that the ripple is persistent and won't fade-out.
      flushTransition();
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      rippleRef.fadeOut();
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });
  });

  describe('global ripple options', () => {
    let rippleDirective: MatRipple;

    function createTestComponent(
      rippleConfig: RippleGlobalOptions,
      testComponent: any = BasicRippleContainer,
      extraImports: any[] = [],
    ) {
      // Reset the previously configured testing module to be able set new providers.
      // The testing module has been initialized in the root describe group for the ripples.
      TestBed.resetTestingModule();
      TestBed.configureTestingModule({
        imports: [MatRippleModule, ...extraImports, testComponent],
        providers: [{provide: MAT_RIPPLE_GLOBAL_OPTIONS, useValue: rippleConfig}],
      });

      fixture = TestBed.createComponent(testComponent);
      fixture.detectChanges();

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

    it('should work without having any binding set', () => {
      createTestComponent({disabled: true}, RippleContainerWithoutBindings);

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

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);

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

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('when disabled should not show any ripples on mousedown', () => {
      createTestComponent({disabled: true});

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

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);

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

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('when disabled should still allow manual ripples', () => {
      createTestComponent({disabled: true});

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);

      rippleDirective.launch(0, 0);

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);
    });

    it('should support changing the animation duration', () => {
      createTestComponent({
        animation: {enterDuration: 100, exitDuration: 150},
      });

      const rippleRef = rippleDirective.launch(0, 0);

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      expect(rippleRef.element.style.transitionDuration).toBe('100ms');
      flushTransition();

      expect(rippleRef.element.style.transitionDuration).toBe('150ms');
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('should allow ripples to fade out immediately on pointer up', () => {
      createTestComponent({
        terminateOnPointerUp: true,
      });

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

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      // Just flush the fade-out duration because we immediately fired the mouseup after the
      // mousedown. This means that the ripple should just fade out, and there shouldn't be an
      // enter animation.
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('should not mutate the global options when NoopAnimationsModule is present', () => {
      const options: RippleGlobalOptions = {};

      createTestComponent(options, RippleContainerWithoutBindings, [NoopAnimationsModule]);

      expect(options.animation).toBeFalsy();
    });
  });

  describe('with disabled animations', () => {
    let rippleDirective: MatRipple;

    beforeEach(() => {
      TestBed.resetTestingModule();
      TestBed.configureTestingModule({
        imports: [NoopAnimationsModule, MatRippleModule, BasicRippleContainer],
      });

      fixture = TestBed.createComponent(BasicRippleContainer);
      fixture.detectChanges();

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

    it('should set the animation durations to zero', () => {
      expect(rippleDirective.rippleConfig.animation!.enterDuration).toBe(0);
      expect(rippleDirective.rippleConfig.animation!.exitDuration).toBe(0);
    });
  });

  describe('configuring behavior', () => {
    let controller: RippleContainerWithInputBindings;

    beforeEach(() => {
      fixture = TestBed.createComponent(RippleContainerWithInputBindings);
      fixture.detectChanges();

      controller = fixture.debugElement.componentInstance;
      rippleTarget = fixture.debugElement.nativeElement.querySelector('.mat-ripple');
    });

    it('sets ripple color', () => {
      const backgroundColor = 'rgba(12, 34, 56, 0.8)';

      controller.color = backgroundColor;
      fixture.changeDetectorRef.markForCheck();
      fixture.detectChanges();

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

      let ripple = rippleTarget.querySelector('.mat-ripple-element')!;
      expect(window.getComputedStyle(ripple).backgroundColor).toBe(backgroundColor);
    });

    it('does not respond to events when disabled input is set', () => {
      controller.disabled = true;
      fixture.changeDetectorRef.markForCheck();
      fixture.detectChanges();

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

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);

      controller.disabled = false;
      fixture.changeDetectorRef.markForCheck();
      fixture.detectChanges();

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

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);
    });

    it('fades out non-persistent ripples when disabled input is set', () => {
      dispatchMouseEvent(rippleTarget, 'mousedown');
      controller.ripple.launch(0, 0, {persistent: true});

      flushTransition();
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(2);

      spyOn(controller.ripple, 'fadeOutAllNonPersistent').and.callThrough();
      controller.disabled = true;
      fixture.changeDetectorRef.markForCheck();
      fixture.detectChanges();

      expect(controller.ripple.fadeOutAllNonPersistent).toHaveBeenCalled();

      flushTransition();
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);
    });

    it('allows specifying custom trigger element', () => {
      let alternateTrigger = fixture.debugElement.nativeElement.querySelector(
        '.alternateTrigger',
      ) as HTMLElement;

      dispatchMouseEvent(alternateTrigger, 'mousedown');
      dispatchMouseEvent(alternateTrigger, 'mouseup');

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);

      // Set the trigger element, and now events should create ripples.
      controller.trigger = alternateTrigger;
      fixture.changeDetectorRef.markForCheck();
      fixture.detectChanges();

      dispatchMouseEvent(alternateTrigger, 'mousedown');
      dispatchMouseEvent(alternateTrigger, 'mouseup');

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);
    });

    it('expands ripple from center if centered input is set', () => {
      controller.centered = true;
      fixture.changeDetectorRef.markForCheck();
      fixture.detectChanges();

      let elementRect = rippleTarget.getBoundingClientRect();

      // Click the ripple element 50 px to the right and 75px down from its upper left.
      dispatchMouseEvent(rippleTarget, 'mousedown', 50, 75);
      dispatchMouseEvent(rippleTarget, 'mouseup');

      // Because the centered input is true, the center of the ripple should be the midpoint of the
      // bounding rect. The ripple should expand to cover the rect corners, which are 150px
      // horizontally and 100px vertically from the midpoint.
      let expectedRadius = Math.sqrt(150 * 150 + 100 * 100);
      let expectedLeft = elementRect.left + elementRect.width / 2 - expectedRadius;
      let expectedTop = elementRect.top + elementRect.height / 2 - expectedRadius;

      let ripple = rippleTarget.querySelector('.mat-ripple-element') as HTMLElement;

      expect(pxStringToFloat(ripple.style.left)).toBeCloseTo(expectedLeft, 1);
      expect(pxStringToFloat(ripple.style.top)).toBeCloseTo(expectedTop, 1);
      expect(pxStringToFloat(ripple.style.width)).toBeCloseTo(2 * expectedRadius, 1);
      expect(pxStringToFloat(ripple.style.height)).toBeCloseTo(2 * expectedRadius, 1);
    });

    it('uses custom radius if set', () => {
      let customRadius = 42;

      controller.radius = customRadius;
      fixture.changeDetectorRef.markForCheck();
      fixture.detectChanges();

      let elementRect = rippleTarget.getBoundingClientRect();

      // Click the ripple element 50 px to the right and 75px down from its upper left.
      dispatchMouseEvent(rippleTarget, 'mousedown', 50, 75);
      dispatchMouseEvent(rippleTarget, 'mouseup');

      let expectedLeft = elementRect.left + 50 - customRadius;
      let expectedTop = elementRect.top + 75 - customRadius;

      let ripple = rippleTarget.querySelector('.mat-ripple-element') as HTMLElement;

      expect(pxStringToFloat(ripple.style.left)).toBeCloseTo(expectedLeft, 1);
      expect(pxStringToFloat(ripple.style.top)).toBeCloseTo(expectedTop, 1);
      expect(pxStringToFloat(ripple.style.width)).toBeCloseTo(2 * customRadius, 1);
      expect(pxStringToFloat(ripple.style.height)).toBeCloseTo(2 * customRadius, 1);
    });

    it('should be able to specify animation config through binding', () => {
      controller.animationConfig = {enterDuration: 120, exitDuration: 150};
      fixture.changeDetectorRef.markForCheck();
      fixture.detectChanges();

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

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      const rippleElement = rippleTarget.querySelector('.mat-ripple-element')! as HTMLElement;

      expect(rippleElement.style.transitionDuration).toBe('120ms');
      flushTransition();

      expect(rippleElement.style.transitionDuration).toBe('150ms');
      flushTransition();

      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });
  });

  describe('edge cases', () => {
    it('should handle forcibly disabled animations through CSS `transition: none`', async () => {
      fixture = TestBed.createComponent(RippleCssTransitionNone);
      fixture.detectChanges();

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

      dispatchMouseEvent(rippleTarget, 'mousedown');
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      dispatchMouseEvent(rippleTarget, 'mouseup');
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0);
    });

    it('should handle forcibly disabled animations through CSS `transition-duration: 0ms`', async () => {
      fixture = TestBed.createComponent(RippleCssTransitionDurationZero);
      fixture.detectChanges();

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

      dispatchMouseEvent(rippleTarget, 'mousedown');
      expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1);

      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();
    });
  });
});

@Component({
  template: `
    <div id="container" #ripple="matRipple" matRipple
         style="position: relative; width:300px; height:200px;">
    </div>
  `,
  standalone: true,
  imports: [MatRippleModule],
})
class BasicRippleContainer {
  @ViewChild('ripple') ripple: MatRipple;
}

@Component({
  template: `
    <div id="container" style="position: relative; width:300px; height:200px;"
      matRipple
      [matRippleTrigger]="trigger"
      [matRippleCentered]="centered"
      [matRippleRadius]="radius"
      [matRippleDisabled]="disabled"
      [matRippleAnimation]="animationConfig"
      [matRippleColor]="color">
    </div>
    <div class="alternateTrigger"></div>
  `,
  standalone: true,
  imports: [MatRippleModule],
})
class RippleContainerWithInputBindings {
  animationConfig: RippleAnimationConfig;
  trigger: HTMLElement;
  centered = false;
  disabled = false;
  radius = 0;
  color = '';
  @ViewChild(MatRipple) ripple: MatRipple;
}

@Component({
  template: `<div id="container" #ripple="matRipple" matRipple></div>`,
  standalone: true,
  imports: [MatRippleModule],
})
class RippleContainerWithoutBindings {}

@Component({
  template: `@if (!isDestroyed) {<div id="container" matRipple></div>}`,
  standalone: true,
  imports: [MatRippleModule],
})
class RippleContainerWithNgIf {
  @ViewChild(MatRipple) ripple: MatRipple;
  isDestroyed = false;
}

@Component({
  styles: `* { transition: none !important; }`,
  template: `<div id="container" matRipple></div>`,
  encapsulation: ViewEncapsulation.None,
  standalone: true,
  imports: [MatRippleModule],
})
class RippleCssTransitionNone {}

@Component({
  styles: `* { transition-duration: 0ms !important; }`,
  template: `<div id="container" matRipple></div>`,
  encapsulation: ViewEncapsulation.None,
  standalone: true,
  imports: [MatRippleModule],
})
class RippleCssTransitionDurationZero {}

@Component({
  template: `
    @if (show) {
      <div (click)="show = false" matRipple>Click to remove this element.</div>
    }
  `,
  standalone: true,
  imports: [MatRippleModule],
})
class RippleWithDomRemovalOnClick {
  show = true;
}
