From 47448cb0c4824ba8b6a748df5f25b15d675cbf02 Mon Sep 17 00:00:00 2001 From: Brian Nenninger Date: Mon, 25 Jul 2016 16:18:00 -0400 Subject: [PATCH] feat(ripple): initial mdInkRipple implementation (#681) * Initial mdInkRipple implementation. * Add missing files. * Remove unused code. * Fix stylelint errors. * In-progress updates for PR comments. * More PR comments. * Fix tests, use @internal. * Restore original body margin after tests. * Add "unbounded" and "max-radius" bindings. * Tweaking ripple color and speed. * Fix ripple scaling. * In-progress updates for PR comments. * PR comments * Fix maxRadius binding in tests. * Simplify ripple demo @ViewChild. * Switch to attribute directive (
instead of ) and move to core. * Change MdInkRipple identifiers to MdRipple, remove duplicate CSS file. --- src/core/core.ts | 3 + src/core/ripple/README.md | 27 +++ src/core/ripple/ripple-renderer.ts | 175 ++++++++++++++ src/core/ripple/ripple.spec.ts | 342 +++++++++++++++++++++++++++ src/core/ripple/ripple.ts | 168 +++++++++++++ src/core/style/_ripple.scss | 62 +++++ src/core/style/core.scss | 2 + src/demo-app/demo-app/demo-app.html | 1 + src/demo-app/demo-app/routes.ts | 2 + src/demo-app/index.html | 1 + src/demo-app/ripple/ripple-demo.html | 52 ++++ src/demo-app/ripple/ripple-demo.scss | 35 +++ src/demo-app/ripple/ripple-demo.ts | 50 ++++ 13 files changed, 920 insertions(+) create mode 100644 src/core/ripple/README.md create mode 100644 src/core/ripple/ripple-renderer.ts create mode 100644 src/core/ripple/ripple.spec.ts create mode 100644 src/core/ripple/ripple.ts create mode 100644 src/core/style/_ripple.scss create mode 100644 src/demo-app/ripple/ripple-demo.html create mode 100644 src/demo-app/ripple/ripple-demo.scss create mode 100644 src/demo-app/ripple/ripple-demo.ts diff --git a/src/core/core.ts b/src/core/core.ts index 956b1df7810b..849f1cf16f46 100644 --- a/src/core/core.ts +++ b/src/core/core.ts @@ -30,6 +30,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 BasicRippleContainer { + @ViewChild(MdRipple) ripple: MdRipple; +} + +@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 451170190e09..be42f90df9fb 100644 --- a/src/demo-app/demo-app/demo-app.html +++ b/src/demo-app/demo-app/demo-app.html @@ -18,6 +18,7 @@ Progress Bar Progress Circle Radio + Ripple Sidenav Slider Slide Toggle diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index b75b543da341..7215eeafe702 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'; import {TooltipDemo} from '../tooltip/tooltip-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}, {path: 'tooltip', component: TooltipDemo}, ]; 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 @@ + +
+
+ + + + +
+ +
+
Centered
+
Unbounded
+
Disabled
+
Rounded container (flaky in Firefox)
+
+
+ Speed + + Slow + Normal + Fast + +
+
+ + + +
+
+ +
+
+
+ Click me +
+
+ {{centered.checked}} {{rounded.checked}} +
diff --git a/src/demo-app/ripple/ripple-demo.scss b/src/demo-app/ripple/ripple-demo.scss new file mode 100644 index 000000000000..326288d23013 --- /dev/null +++ b/src/demo-app/ripple/ripple-demo.scss @@ -0,0 +1,35 @@ +.demo-ripple { + button, a { + margin: 8px; + text-transform: uppercase; + } + + .demo-ripple-container { + box-shadow: rgba(0, 0, 0, 0.118) 0 10px 15px, rgba(0, 0, 0, 0.239) 0 5px 10px; + cursor: pointer; + font-size: 24px; + height: 150px; + line-height: 150px; + position: relative; + text-align: center; + transition: all 200ms linear; + width: 200px; + user-select: none; + + &.demo-ripple-disabled { + color: rgba(32, 32, 32, 0.4); + } + + &.demo-ripple-rounded { + border-radius: 50%; + overflow: hidden; + // z-index needed to make clipping to border-radius work correctly. + // http://stackoverflow.com/questions/20001515/chrome-bug-border-radius-not-clipping-contents-when-combined-with-css-transiti + z-index: 1; + } + } + + .demo-ripple-checkbox-option { + margin: 10px 0; + } +} \ No newline at end of file diff --git a/src/demo-app/ripple/ripple-demo.ts b/src/demo-app/ripple/ripple-demo.ts new file mode 100644 index 000000000000..e2be9f254852 --- /dev/null +++ b/src/demo-app/ripple/ripple-demo.ts @@ -0,0 +1,50 @@ +import { + Component, + ViewChild, +} from '@angular/core'; +import {MD_BUTTON_DIRECTIVES} from '@angular2-material/button/button'; +import {MD_CARD_DIRECTIVES} from '@angular2-material/card/card'; +import {MD_CHECKBOX_DIRECTIVES} from '@angular2-material/checkbox/checkbox'; +import {MD_ICON_DIRECTIVES} from '@angular2-material/icon/icon'; +import {MD_INPUT_DIRECTIVES} from '@angular2-material/input/input'; +import {MD_RADIO_DIRECTIVES} from '@angular2-material/radio/radio'; +import { + MdUniqueSelectionDispatcher +} from '@angular2-material/core/coordination/unique-selection-dispatcher'; +import {MD_RIPPLE_DIRECTIVES, MdRipple} from '@angular2-material/core/core'; + +@Component({ + moduleId: module.id, + selector: 'ripple-demo', + templateUrl: 'ripple-demo.html', + styleUrls: ['ripple-demo.css'], + providers: [MdUniqueSelectionDispatcher], + directives: [ + MD_BUTTON_DIRECTIVES, + MD_CARD_DIRECTIVES, + MD_CHECKBOX_DIRECTIVES, + MD_ICON_DIRECTIVES, + MD_INPUT_DIRECTIVES, + MD_RADIO_DIRECTIVES, + MD_RIPPLE_DIRECTIVES, + ], +}) +export class RippleDemo { + @ViewChild(MdRipple) manualRipple: MdRipple; + + centered = false; + disabled = false; + unbounded = false; + rounded = false; + maxRadius: number = null; + rippleSpeed = 1; + rippleColor = ''; + rippleBackgroundColor = ''; + + doManualRipple() { + if (this.manualRipple) { + window.setTimeout(() => this.manualRipple.start(), 10); + window.setTimeout(() => this.manualRipple.end(0, 0), 500); + } + } +}