diff --git a/src/core/core.ts b/src/core/core.ts
index cffa1091703f..004a65431b0a 100644
--- a/src/core/core.ts
+++ b/src/core/core.ts
@@ -29,6 +29,9 @@ export {
// Gestures
export {MdGestureConfig} from './gestures/MdGestureConfig';
+// Ripple
+export {MD_RIPPLE_DIRECTIVES, MdRipple} from './ripple/ripple';
+
// a11y
export {
AriaLivePoliteness,
diff --git a/src/core/ripple/README.md b/src/core/ripple/README.md
new file mode 100644
index 000000000000..48f6dd628268
--- /dev/null
+++ b/src/core/ripple/README.md
@@ -0,0 +1,27 @@
+# md-ripple
+
+`md-ripple` defines an area in which a ripple animates, usually in response to user action. It is used as an attribute directive, for example `
...
`.
+
+By default, a ripple is activated when the host element of the `md-ripple` directive receives mouse or touch events. On a mousedown or touch start, the ripple background fades in. When the click event completes, a circular foreground ripple fades in and expands from the event location to cover the host element bounds.
+
+Ripples can also be triggered programmatically by getting a reference to the MdRipple directive and calling its `start` and `end` methods.
+
+
+### Upcoming work
+
+Ripples will be added to the `md-button`, `md-radio-button`, `md-checkbox`, and `md-nav-list` components.
+
+### API Summary
+
+Properties:
+
+| Name | Type | Description |
+| --- | --- | --- |
+| `md-ripple-trigger` | Element | The DOM element that triggers the ripple when clicked. Defaults to the parent of the `md-ripple`.
+| `md-ripple-color` | string | Custom color for foreground ripples
+| `md-ripple-background-color` | string | Custom color for the ripple background
+| `md-ripple-centered` | boolean | If true, the ripple animation originates from the center of the `md-ripple` bounds rather than from the location of the click event.
+| `md-ripple-max-radius` | number | Optional fixed radius of foreground ripples when fully expanded. Mainly used in conjunction with `unbounded` attribute. If not set, ripples will expand from their origin to the most distant corner of the component's bounding rectangle.
+| `md-ripple-unbounded` | boolean | If true, foreground ripples will be visible outside the component's bounds.
+| `md-ripple-focused` | boolean | If true, the background ripple is shown using the current theme's accent color to indicate focus.
+| `md-ripple-disabled` | boolean | If true, click events on the trigger element will not activate ripples. The `start` and `end` methods can still be called to programmatically create ripples.
diff --git a/src/core/ripple/ripple-renderer.ts b/src/core/ripple/ripple-renderer.ts
new file mode 100644
index 000000000000..396c630c8cb9
--- /dev/null
+++ b/src/core/ripple/ripple-renderer.ts
@@ -0,0 +1,175 @@
+import {
+ ElementRef,
+} from '@angular/core';
+
+/** TODO: internal */
+export enum ForegroundRippleState {
+ NEW,
+ EXPANDING,
+ FADING_OUT,
+}
+
+/**
+ * Wrapper for a foreground ripple DOM element and its animation state.
+ * TODO: internal
+ */
+export class ForegroundRipple {
+ state = ForegroundRippleState.NEW;
+ constructor(public rippleElement: Element) {}
+}
+
+const RIPPLE_SPEED_PX_PER_SECOND = 1000;
+const MIN_RIPPLE_FILL_TIME_SECONDS = 0.1;
+const MAX_RIPPLE_FILL_TIME_SECONDS = 0.3;
+
+/**
+ * Returns the distance from the point (x, y) to the furthest corner of a rectangle.
+ */
+const distanceToFurthestCorner = (x: number, y: number, rect: ClientRect) => {
+ const distX = Math.max(Math.abs(x - rect.left), Math.abs(x - rect.right));
+ const distY = Math.max(Math.abs(y - rect.top), Math.abs(y - rect.bottom));
+ return Math.sqrt(distX * distX + distY * distY);
+};
+
+/**
+ * Helper service that performs DOM manipulations. Not intended to be used outside this module.
+ * The constructor takes a reference to the ripple directive's host element and a map of DOM
+ * event handlers to be installed on the element that triggers ripple animations.
+ * This will eventually become a custom renderer once Angular support exists.
+ * TODO: internal
+ */
+export class RippleRenderer {
+ private _backgroundDiv: HTMLElement;
+ private _rippleElement: HTMLElement;
+ private _triggerElement: HTMLElement;
+
+ constructor(_elementRef: ElementRef, private _eventHandlers: Map void>) {
+ this._rippleElement = _elementRef.nativeElement;
+ // It might be nice to delay creating the background until it's needed, but doing this in
+ // fadeInRippleBackground causes the first click event to not be handled reliably.
+ this._backgroundDiv = document.createElement('div');
+ this._backgroundDiv.classList.add('md-ripple-background');
+ this._rippleElement.appendChild(this._backgroundDiv);
+ }
+
+ /**
+ * Installs event handlers on the given trigger element, and removes event handlers from the
+ * previous trigger if needed.
+ */
+ setTriggerElement(newTrigger: HTMLElement) {
+ if (this._triggerElement !== newTrigger) {
+ if (this._triggerElement) {
+ this._eventHandlers.forEach((eventHandler, eventName) => {
+ this._triggerElement.removeEventListener(eventName, eventHandler);
+ });
+ }
+ this._triggerElement = newTrigger;
+ if (this._triggerElement) {
+ this._eventHandlers.forEach((eventHandler, eventName) => {
+ this._triggerElement.addEventListener(eventName, eventHandler);
+ });
+ }
+ }
+ }
+
+ /**
+ * Installs event handlers on the host element of the md-ripple directive.
+ */
+ setTriggerElementToHost() {
+ this.setTriggerElement(this._rippleElement);
+ }
+
+ /**
+ * Removes event handlers from the current trigger element if needed.
+ */
+ clearTriggerElement() {
+ this.setTriggerElement(null);
+ }
+
+ /**
+ * Creates a foreground ripple and sets its animation to expand and fade in from the position
+ * given by rippleOriginLeft and rippleOriginTop (or from the center of the
+ * bounding rect if centered is true).
+ */
+ createForegroundRipple(
+ rippleOriginLeft: number,
+ rippleOriginTop: number,
+ color: string,
+ centered: boolean,
+ radius: number,
+ speedFactor: number,
+ transitionEndCallback: (r: ForegroundRipple, e: TransitionEvent) => void) {
+ const parentRect = this._rippleElement.getBoundingClientRect();
+ // Create a foreground ripple div with the size and position of the fully expanded ripple.
+ // When the div is created, it's given a transform style that causes the ripple to be displayed
+ // small and centered on the event location (or the center of the bounding rect if the centered
+ // argument is true). Removing that transform causes the ripple to animate to its natural size.
+ const startX = centered ? (parentRect.left + parentRect.width / 2) : rippleOriginLeft;
+ const startY = centered ? (parentRect.top + parentRect.height / 2) : rippleOriginTop;
+ const offsetX = startX - parentRect.left;
+ const offsetY = startY - parentRect.top;
+ const maxRadius = radius > 0 ? radius : distanceToFurthestCorner(startX, startY, parentRect);
+
+ const rippleDiv = document.createElement('div');
+ this._rippleElement.appendChild(rippleDiv);
+ rippleDiv.classList.add('md-ripple-foreground');
+ rippleDiv.style.left = `${offsetX - maxRadius}px`;
+ rippleDiv.style.top = `${offsetY - maxRadius}px`;
+ rippleDiv.style.width = `${2 * maxRadius}px`;
+ rippleDiv.style.height = rippleDiv.style.width;
+ // If color input is not set, this will default to the background color defined in CSS.
+ rippleDiv.style.backgroundColor = color;
+ // Start the ripple tiny.
+ rippleDiv.style.transform = `scale(0.001)`;
+
+ const fadeInSeconds = (1 / (speedFactor || 1)) * Math.max(
+ MIN_RIPPLE_FILL_TIME_SECONDS,
+ Math.min(MAX_RIPPLE_FILL_TIME_SECONDS, maxRadius / RIPPLE_SPEED_PX_PER_SECOND));
+ rippleDiv.style.transitionDuration = `${fadeInSeconds}s`;
+
+ // https://timtaubert.de/blog/2012/09/css-transitions-for-dynamically-created-dom-elements/
+ window.getComputedStyle(rippleDiv).opacity;
+
+ rippleDiv.classList.add('md-ripple-fade-in');
+ // Clearing the transform property causes the ripple to animate to its full size.
+ rippleDiv.style.transform = '';
+ const ripple = new ForegroundRipple(rippleDiv);
+ ripple.state = ForegroundRippleState.EXPANDING;
+
+ rippleDiv.addEventListener('transitionend',
+ (event: TransitionEvent) => transitionEndCallback(ripple, event));
+ }
+
+ /**
+ * Fades out a foreground ripple after it has fully expanded and faded in.
+ */
+ fadeOutForegroundRipple(ripple: Element) {
+ ripple.classList.remove('md-ripple-fade-in');
+ ripple.classList.add('md-ripple-fade-out');
+ }
+
+ /**
+ * Removes a foreground ripple from the DOM after it has faded out.
+ */
+ removeRippleFromDom(ripple: Element) {
+ ripple.parentElement.removeChild(ripple);
+ }
+
+ /**
+ * Fades in the ripple background.
+ */
+ fadeInRippleBackground(color: string) {
+ this._backgroundDiv.classList.add('md-ripple-active');
+ // If color is not set, this will default to the background color defined in CSS.
+ this._backgroundDiv.style.backgroundColor = color;
+ }
+
+ /**
+ * Fades out the ripple background.
+ */
+ fadeOutRippleBackground() {
+ if (this._backgroundDiv) {
+ this._backgroundDiv.classList.remove('md-ripple-active');
+ }
+ }
+}
diff --git a/src/core/ripple/ripple.spec.ts b/src/core/ripple/ripple.spec.ts
new file mode 100644
index 000000000000..c0c6b34e3a18
--- /dev/null
+++ b/src/core/ripple/ripple.spec.ts
@@ -0,0 +1,342 @@
+import {
+ describe,
+ it,
+ beforeEach,
+ afterEach,
+ inject,
+ async,
+ expect,
+} from '@angular/core/testing';
+import {TestComponentBuilder, ComponentFixture} from '@angular/compiler/testing';
+import {Component, ViewChild} from '@angular/core';
+import {MdRipple} from './ripple';
+
+/** Creates a DOM event to indicate that a CSS transition for the given property ended. */
+const createTransitionEndEvent = (propertyName: string) => {
+ // The "new" TransitionEvent constructor isn't available in anything except Firefox:
+ // https://developer.mozilla.org/en-US/docs/Web/API/TransitionEvent
+ // So we just try to create a base event, and IE11 doesn't support that so we have to use
+ // the deprecated initTransitionEvent.
+ try {
+ const event = new Event('transitionend');
+ (event).propertyName = propertyName;
+ return event;
+ } catch (e) {
+ const event = document.createEvent('TransitionEvent');
+ event.initTransitionEvent('transitionend',
+ false, /* canBubble */
+ false, /* cancelable */
+ propertyName,
+ 0 /* elapsedTime */);
+ return event;
+ }
+};
+
+/** Creates a DOM mouse event. */
+const createMouseEvent = (eventType: string, dict: any = {}) => {
+ // Ideally this would just be "return new MouseEvent(eventType, dict)". But IE11 doesn't support
+ // the MouseEvent constructor, and Edge inexplicably divides clientX and clientY by 100 to get
+ // pageX and pageY. (Really. After "e = new MouseEvent('click', {clientX: 200, clientY: 300})",
+ // e.clientX is 200, e.pageX is 2, e.clientY is 300, and e.pageY is 3.)
+ // So instead we use the deprecated createEvent/initMouseEvent API, which works everywhere.
+ const event = document.createEvent('MouseEvents');
+ event.initMouseEvent(eventType,
+ false, /* canBubble */
+ false, /* cancelable */
+ window, /* view */
+ 0, /* detail */
+ dict.screenX || 0,
+ dict.screenY || 0,
+ dict.clientX || 0,
+ dict.clientY || 0,
+ false, /* ctrlKey */
+ false, /* altKey */
+ false, /* shiftKey */
+ false, /* metaKey */
+ 0, /* button */
+ null /* relatedTarget */);
+ return event;
+};
+
+/** Extracts the numeric value of a pixel size string like '123px'. */
+const pxStringToFloat = (s: string) => {
+ return parseFloat(s.replace('px', ''));
+};
+
+describe('MdRipple', () => {
+ let builder: TestComponentBuilder;
+ let fixture: ComponentFixture;
+ let rippleElement: HTMLElement;
+ let rippleBackground: Element;
+ let originalBodyMargin: string;
+
+ beforeEach(() => {
+ // 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;
+ });
+
+ beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => {
+ builder = tcb;
+ }));
+
+ describe('basic ripple', () => {
+ beforeEach(async(() => {
+ builder.createAsync(BasicRippleContainer).then(f => {
+ fixture = f;
+ fixture.detectChanges();
+
+ rippleElement = fixture.debugElement.nativeElement.querySelector('[md-ripple]');
+ rippleBackground = rippleElement.querySelector('.md-ripple-background');
+ expect(rippleBackground).toBeTruthy();
+ });
+ }));
+
+ it('shows background when parent receives mousedown event', () => {
+ expect(rippleBackground.classList).not.toContain('md-ripple-active');
+ const mouseDown = createMouseEvent('mousedown');
+ // mousedown on the ripple element activates the background ripple.
+ rippleElement.dispatchEvent(mouseDown);
+ expect(rippleBackground.classList).toContain('md-ripple-active');
+ // mouseleave on the container removes the background ripple.
+ const mouseLeave = createMouseEvent('mouseleave');
+ rippleElement.dispatchEvent(mouseLeave);
+ expect(rippleBackground.classList).not.toContain('md-ripple-active');
+ });
+
+ it('creates foreground ripples on click', () => {
+ rippleElement.click();
+ expect(rippleElement.querySelectorAll('.md-ripple-foreground').length).toBe(1);
+ // Second click should create another ripple.
+ rippleElement.click();
+ const ripples = rippleElement.querySelectorAll('.md-ripple-foreground');
+ expect(ripples.length).toBe(2);
+ expect(ripples[0].classList).toContain('md-ripple-fade-in');
+ expect(ripples[1].classList).toContain('md-ripple-fade-in');
+ // Signal the end of the first ripple's expansion. The second ripple should be unaffected.
+ const opacityTransitionEnd = createTransitionEndEvent('opacity');
+ ripples[0].dispatchEvent(opacityTransitionEnd);
+ expect(ripples[0].classList).not.toContain('md-ripple-fade-in');
+ expect(ripples[0].classList).toContain('md-ripple-fade-out');
+ expect(ripples[1].classList).toContain('md-ripple-fade-in');
+ expect(ripples[1].classList).not.toContain('md-ripple-fade-out');
+ // Signal the end of the first ripple's fade out. The ripple should be removed from the DOM.
+ ripples[0].dispatchEvent(opacityTransitionEnd);
+ expect(rippleElement.querySelectorAll('.md-ripple-foreground').length).toBe(1);
+ expect(rippleElement.querySelectorAll('.md-ripple-foreground')[0]).toBe(ripples[1]);
+ // Finish the second ripple.
+ ripples[1].dispatchEvent(opacityTransitionEnd);
+ expect(ripples[1].classList).not.toContain('md-ripple-fade-in');
+ expect(ripples[1].classList).toContain('md-ripple-fade-out');
+ ripples[1].dispatchEvent(opacityTransitionEnd);
+ expect(rippleElement.querySelectorAll('.md-ripple-foreground').length).toBe(0);
+ });
+
+ it('creates ripples when manually triggered', () => {
+ const rippleComponent = fixture.debugElement.componentInstance.ripple;
+ // start() should show the background, but no foreground ripple yet.
+ rippleComponent.start();
+ expect(rippleBackground.classList).toContain('md-ripple-active');
+ expect(rippleElement.querySelectorAll('.md-ripple-foreground').length).toBe(0);
+ // end() should deactivate the background and show the foreground ripple.
+ rippleComponent.end(0, 0);
+ expect(rippleBackground.classList).not.toContain('md-ripple-active');
+ expect(rippleElement.querySelectorAll('.md-ripple-foreground').length).toBe(1);
+ });
+
+ it('sizes ripple to cover element', () => {
+ // Click the ripple element 50 px to the right and 75px down from its upper left.
+ const elementRect = rippleElement.getBoundingClientRect();
+ const clickEvent = createMouseEvent('click',
+ {clientX: elementRect.left + 50, clientY: elementRect.top + 75});
+ rippleElement.dispatchEvent(clickEvent);
+ // 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.
+ const expectedRadius = Math.sqrt(250 * 250 + 125 * 125);
+ const expectedLeft = elementRect.left + 50 - expectedRadius;
+ const expectedTop = elementRect.top + 75 - expectedRadius;
+ const ripple = rippleElement.querySelector('.md-ripple-foreground');
+ // 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('expands ripple from center on click event triggered by keyboard', () => {
+ const elementRect = rippleElement.getBoundingClientRect();
+ // Simulate a keyboard-triggered click by setting event coordinates to 0.
+ const clickEvent = createMouseEvent('click',
+ {clientX: 0, clientY: 0, screenX: 0, screenY: 0});
+ rippleElement.dispatchEvent(clickEvent);
+ // The foreground ripple should be centered in the middle of the bounding rect, and large
+ // enough to reach the corners, which are all 150px horizontally and 100px vertically away.
+ const expectedRadius = Math.sqrt(150 * 150 + 100 * 100);
+ const expectedLeft = elementRect.left + (elementRect.width / 2) - expectedRadius;
+ const expectedTop = elementRect.top + (elementRect.height / 2) - expectedRadius;
+ // Note: getBoundingClientRect won't work because there's a transform applied to make the
+ // ripple start out tiny.
+ const ripple = rippleElement.querySelector('.md-ripple-foreground');
+ 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('configuring behavior', () => {
+ let controller: RippleContainerWithInputBindings;
+ let rippleComponent: MdRipple;
+
+ beforeEach(async(() => {
+ builder.createAsync(RippleContainerWithInputBindings).then(f => {
+ fixture = f;
+ fixture.detectChanges();
+
+ controller = fixture.debugElement.componentInstance;
+ rippleComponent = controller.ripple;
+ rippleElement = fixture.debugElement.nativeElement.querySelector('[md-ripple]');
+ rippleBackground = rippleElement.querySelector('.md-ripple-background');
+ expect(rippleBackground).toBeTruthy();
+ });
+ }));
+
+ it('sets ripple background color', () => {
+ // This depends on the exact color format that getComputedStyle returns; for example, alpha
+ // values are quantized to increments of 1/255, so 0.1 becomes 0.0980392. 0.2 is ok.
+ const color = 'rgba(22, 44, 66, 0.8)';
+ controller.backgroundColor = color;
+ fixture.detectChanges();
+ rippleComponent.start();
+ expect(window.getComputedStyle(rippleBackground).backgroundColor).toBe(color);
+ });
+
+ it('sets ripple foreground color', () => {
+ const color = 'rgba(12, 34, 56, 0.8)';
+ controller.color = color;
+ fixture.detectChanges();
+ rippleElement.click();
+ const ripple = rippleElement.querySelector('.md-ripple-foreground');
+ expect(window.getComputedStyle(ripple).backgroundColor).toBe(color);
+ });
+
+ it('does not respond to events when disabled input is set', () => {
+ controller.disabled = true;
+ fixture.detectChanges();
+ const mouseDown = createMouseEvent('mousedown');
+ // The background ripple should not respond to mouseDown, and no foreground ripple should be
+ // created on a click.
+ rippleElement.dispatchEvent(mouseDown);
+ expect(rippleBackground.classList).not.toContain('md-ripple-active');
+ rippleElement.click();
+ expect(rippleElement.querySelectorAll('.md-ripple-foreground').length).toBe(0);
+ // Calling start() and end() should still create a ripple.
+ rippleComponent.start();
+ expect(rippleBackground.classList).toContain('md-ripple-active');
+ rippleComponent.end(0, 0);
+ expect(rippleElement.querySelectorAll('.md-ripple-foreground').length).toBe(1);
+ });
+
+ it('allows specifying custom trigger element', () => {
+ // Events on the other div don't do anything by default.
+ const alternateTrigger =
+ fixture.debugElement.nativeElement.querySelector('.alternateTrigger');
+ const mouseDown = createMouseEvent('mousedown');
+ alternateTrigger.dispatchEvent(mouseDown);
+ expect(rippleBackground.classList).not.toContain('md-ripple-active');
+ alternateTrigger.click();
+ expect(rippleElement.querySelectorAll('.md-ripple-foreground').length).toBe(0);
+
+ // Reassign the trigger element, and now events should create ripples.
+ controller.trigger = alternateTrigger;
+ fixture.detectChanges();
+ alternateTrigger.dispatchEvent(mouseDown);
+ expect(rippleBackground.classList).toContain('md-ripple-active');
+ alternateTrigger.click();
+ expect(rippleElement.querySelectorAll('.md-ripple-foreground').length).toBe(1);
+ });
+
+ it('expands ripple from center if centered input is set', () => {
+ controller.centered = true;
+ fixture.detectChanges();
+ // Click the ripple element 50 px to the right and 75px down from its upper left.
+ const elementRect = rippleElement.getBoundingClientRect();
+ const clickEvent = createMouseEvent('click',
+ {clientX: elementRect.left + 50, clientY: elementRect.top + 75});
+ rippleElement.dispatchEvent(clickEvent);
+ // 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.
+ const expectedRadius = Math.sqrt(150 * 150 + 100 * 100);
+ const expectedLeft = elementRect.left + (elementRect.width / 2) - expectedRadius;
+ const expectedTop = elementRect.top + (elementRect.height / 2) - expectedRadius;
+
+ const ripple = rippleElement.querySelector('.md-ripple-foreground');
+ 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', () => {
+ const customRadius = 42;
+ controller.maxRadius = customRadius;
+ fixture.detectChanges();
+ // Click the ripple element 50 px to the right and 75px down from its upper left.
+ const elementRect = rippleElement.getBoundingClientRect();
+ const clickEvent = createMouseEvent('click',
+ {clientX: elementRect.left + 50, clientY: elementRect.top + 75});
+ rippleElement.dispatchEvent(clickEvent);
+ const expectedLeft = elementRect.left + 50 - customRadius;
+ const expectedTop = elementRect.top + 75 - customRadius;
+
+ const ripple = rippleElement.querySelector('.md-ripple-foreground');
+ 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);
+ });
+ });
+});
+
+@Component({
+ directives: [MdRipple],
+ template: `
+
+
+ `,
+})
+class RippleContainerWithInputBindings {
+ trigger: HTMLElement = null;
+ centered = false;
+ disabled = false;
+ maxRadius = 0;
+ color = '';
+ backgroundColor = '';
+ @ViewChild(MdRipple) ripple: MdRipple;
+}
diff --git a/src/core/ripple/ripple.ts b/src/core/ripple/ripple.ts
new file mode 100644
index 000000000000..04e4c7afd8b0
--- /dev/null
+++ b/src/core/ripple/ripple.ts
@@ -0,0 +1,168 @@
+import {
+ Directive,
+ ElementRef,
+ HostBinding,
+ Input,
+ OnChanges,
+ OnDestroy,
+ OnInit,
+ SimpleChange,
+} from '@angular/core';
+import {
+ RippleRenderer,
+ ForegroundRipple,
+ ForegroundRippleState,
+} from './ripple-renderer';
+
+
+@Directive({
+ selector: '[md-ripple]',
+})
+export class MdRipple implements OnInit, OnDestroy, OnChanges {
+ /**
+ * The element that triggers the ripple when click events are received. Defaults to the
+ * directive's host element.
+ */
+ @Input('md-ripple-trigger') trigger: HTMLElement;
+ /**
+ * Whether the ripple always originates from the center of the host element's bounds, rather
+ * than originating from the location of the click event.
+ */
+ @Input('md-ripple-centered') centered: boolean;
+ /**
+ * Whether click events will not trigger the ripple. It can still be triggered by manually
+ * calling start() and end().
+ */
+ @Input('md-ripple-disabled') disabled: boolean;
+ /**
+ * If set, the radius in pixels of foreground ripples when fully expanded. If unset, the radius
+ * will be the distance from the center of the ripple to the furthest corner of the host element's
+ * bounding rectangle.
+ */
+ @Input('md-ripple-max-radius') maxRadius: number = 0;
+ /**
+ * If set, the normal duration of ripple animations is divided by this value. For example,
+ * setting it to 0.5 will cause the animations to take twice as long.
+ */
+ @Input('md-ripple-speed-factor') speedFactor: number = 1;
+ /** Custom color for ripples. */
+ @Input('md-ripple-color') color: string;
+ /** Custom color for the ripple background. */
+ @Input('md-ripple-background-color') backgroundColor: string;
+
+ /** Whether the ripple background will be highlighted to indicated a focused state. */
+ @HostBinding('class.md-ripple-focused') @Input('md-ripple-focused') focused: boolean;
+ /** Whether foreground ripples should be visible outside the component's bounds. */
+ @HostBinding('class.md-ripple-unbounded') @Input('md-ripple-unbounded') unbounded: boolean;
+
+ private _rippleRenderer: RippleRenderer;
+
+ constructor(_elementRef: ElementRef) {
+ // These event handlers are attached to the element that triggers the ripple animations.
+ const eventHandlers = new Map void>();
+ eventHandlers.set('mousedown', (event: MouseEvent) => this._mouseDown(event));
+ eventHandlers.set('click', (event: MouseEvent) => this._click(event));
+ eventHandlers.set('mouseleave', (event: MouseEvent) => this._mouseLeave(event));
+ this._rippleRenderer = new RippleRenderer(_elementRef, eventHandlers);
+ }
+
+ /** TODO: internal */
+ ngOnInit() {
+ // If no trigger element was explicity set, use the host element
+ if (!this.trigger) {
+ this._rippleRenderer.setTriggerElementToHost();
+ }
+ }
+
+ /** TODO: internal */
+ ngOnDestroy() {
+ // Remove event listeners on the trigger element.
+ this._rippleRenderer.clearTriggerElement();
+ }
+
+ /** TODO: internal */
+ ngOnChanges(changes: { [propertyName: string]: SimpleChange }) {
+ // If the trigger element changed (or is being initially set), add event listeners to it.
+ const changedInputs = Object.keys(changes);
+ if (changedInputs.indexOf('trigger') !== -1) {
+ this._rippleRenderer.setTriggerElement(this.trigger);
+ }
+ }
+
+ /**
+ * Responds to the start of a ripple animation trigger by fading the background in.
+ */
+ start() {
+ this._rippleRenderer.fadeInRippleBackground(this.backgroundColor);
+ }
+
+ /**
+ * Responds to the end of a ripple animation trigger by fading the background out, and creating a
+ * foreground ripple that expands from the event location (or from the center of the element if
+ * the "centered" property is set or forceCenter is true).
+ */
+ end(left: number, top: number, forceCenter = true) {
+ this._rippleRenderer.createForegroundRipple(
+ left,
+ top,
+ this.color,
+ this.centered || forceCenter,
+ this.maxRadius,
+ this.speedFactor,
+ (ripple: ForegroundRipple, e: TransitionEvent) => this._rippleTransitionEnded(ripple, e));
+ this._rippleRenderer.fadeOutRippleBackground();
+ }
+
+ private _rippleTransitionEnded(ripple: ForegroundRipple, event: TransitionEvent) {
+ if (event.propertyName === 'opacity') {
+ // If the ripple finished expanding, start fading it out. If it finished fading out,
+ // remove it from the DOM.
+ switch (ripple.state) {
+ case ForegroundRippleState.EXPANDING:
+ this._rippleRenderer.fadeOutForegroundRipple(ripple.rippleElement);
+ ripple.state = ForegroundRippleState.FADING_OUT;
+ break;
+ case ForegroundRippleState.FADING_OUT:
+ this._rippleRenderer.removeRippleFromDom(ripple.rippleElement);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Called when the trigger element receives a mousedown event. Starts the ripple animation by
+ * fading in the background.
+ */
+ private _mouseDown(event: MouseEvent) {
+ if (!this.disabled && event.button === 0) {
+ this.start();
+ }
+ }
+
+ /**
+ * Called when the trigger element receives a click event. Creates a foreground ripple and
+ * runs its animation.
+ */
+ private _click(event: MouseEvent) {
+ if (!this.disabled && event.button === 0) {
+ // If screen and page positions are all 0, this was probably triggered by a keypress.
+ // In that case, use the center of the bounding rect as the ripple origin.
+ // FIXME: This fails on IE11, which still sets pageX/Y and screenX/Y on keyboard clicks.
+ const isKeyEvent =
+ (event.screenX === 0 && event.screenY === 0 && event.pageX === 0 && event.pageY === 0);
+ this.end(event.pageX, event.pageY, isKeyEvent);
+ }
+ }
+
+ /**
+ * Called when the trigger element receives a mouseleave event. Fades out the background.
+ */
+ private _mouseLeave(event: MouseEvent) {
+ // We can always fade out the background here; It's a no-op if it was already inactive.
+ this._rippleRenderer.fadeOutRippleBackground();
+ }
+
+ // TODO: Reactivate the background div if the user drags out and back in.
+}
+
+export const MD_RIPPLE_DIRECTIVES = [MdRipple];
diff --git a/src/core/style/_ripple.scss b/src/core/style/_ripple.scss
new file mode 100644
index 000000000000..61355366f2e7
--- /dev/null
+++ b/src/core/style/_ripple.scss
@@ -0,0 +1,62 @@
+@import 'default-theme';
+@import 'theme-functions';
+
+$md-ripple-focused-opacity: 0.1;
+$md-ripple-background-fade-duration: 300ms;
+$md-ripple-background-default-color: rgba(0, 0, 0, 0.0588);
+$md-ripple-foreground-initial-opacity: 0.25;
+$md-ripple-foreground-default-color: rgba(0, 0, 0, 0.0588);
+
+/**
+ * The host element of an md-ripple directive should always have a position of "absolute" or
+ * "relative" so that the ripple divs it creates inside itself are correctly positioned.
+ */
+[md-ripple] {
+ overflow: hidden;
+}
+
+[md-ripple].md-ripple-unbounded {
+ overflow: visible;
+}
+
+.md-ripple-background {
+ background-color: $md-ripple-background-default-color;
+ opacity: 0;
+ transition: opacity $md-ripple-background-fade-duration linear;
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+}
+
+.md-ripple-unbounded .md-ripple-background {
+ display: none;
+}
+
+.md-ripple-background.md-ripple-active {
+ opacity: 1;
+}
+
+.md-ripple-focused .md-ripple-background {
+ background-color: md-color($md-accent, $md-ripple-focused-opacity);
+ opacity: 1;
+}
+
+.md-ripple-foreground {
+ background-color: $md-ripple-foreground-default-color;
+ border-radius: 50%;
+ pointer-events: none;
+ opacity: $md-ripple-foreground-initial-opacity;
+ position: absolute;
+ // The transition duration is manually set based on the ripple size.
+ transition: 'opacity, transform' 0ms cubic-bezier(0, 0, 0.2, 1);
+}
+
+.md-ripple-foreground.md-ripple-fade-in {
+ opacity: 1;
+}
+
+.md-ripple-foreground.md-ripple-fade-out {
+ opacity: 0;
+}
diff --git a/src/core/style/core.scss b/src/core/style/core.scss
index 1725802f542e..b0027c1c4658 100644
--- a/src/core/style/core.scss
+++ b/src/core/style/core.scss
@@ -10,3 +10,5 @@
@include md-elevation($zValue);
}
}
+
+@import 'ripple';
diff --git a/src/demo-app/demo-app/demo-app.html b/src/demo-app/demo-app/demo-app.html
index b0fbf16f874a..e6744ffddbcd 100644
--- a/src/demo-app/demo-app/demo-app.html
+++ b/src/demo-app/demo-app/demo-app.html
@@ -18,6 +18,7 @@
Progress CircleProgress BarRadio
+ RippleSidenavSliderSlide Toggle
diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts
index 128ce23232a6..3a33ae71189d 100644
--- a/src/demo-app/demo-app/routes.ts
+++ b/src/demo-app/demo-app/routes.ts
@@ -22,6 +22,7 @@ import {SidenavDemo} from '../sidenav/sidenav-demo';
import {RadioDemo} from '../radio/radio-demo';
import {CardDemo} from '../card/card-demo';
import {MenuDemo} from '../menu/menu-demo';
+import {RippleDemo} from '../ripple/ripple-demo';
import {DialogDemo} from '../dialog/dialog-demo';
@@ -50,6 +51,7 @@ export const routes: RouterConfig = [
{path: 'tabs', component: TabsDemo},
{path: 'button-toggle', component: ButtonToggleDemo},
{path: 'baseline', component: BaselineDemo},
+ {path: 'ripple', component: RippleDemo},
{path: 'dialog', component: DialogDemo},
];
diff --git a/src/demo-app/index.html b/src/demo-app/index.html
index dcc54e5d0d55..d2ee30b106b5 100644
--- a/src/demo-app/index.html
+++ b/src/demo-app/index.html
@@ -14,6 +14,7 @@
+
diff --git a/src/demo-app/ripple/ripple-demo.html b/src/demo-app/ripple/ripple-demo.html
new file mode 100644
index 000000000000..9f09de717d09
--- /dev/null
+++ b/src/demo-app/ripple/ripple-demo.html
@@ -0,0 +1,52 @@
+
+