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 (<div md-ink-ripple> instead of <md-ink-ripple>) and move to core.

* Change MdInkRipple identifiers to MdRipple, remove duplicate CSS file.
This commit is contained in:
Brian Nenninger
2016-07-25 16:18:00 -04:00
committed by Hans
parent 54c61586a0
commit 47448cb0c4
13 changed files with 920 additions and 0 deletions
+3
View File
@@ -30,6 +30,9 @@ export {
// Gestures
export {MdGestureConfig} from './gestures/MdGestureConfig';
// Ripple
export {MD_RIPPLE_DIRECTIVES, MdRipple} from './ripple/ripple';
// a11y
export {
AriaLivePoliteness,
+27
View File
@@ -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 `<div md-ripple [md-ripple-color]="rippleColor">...</div>`.
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.
+175
View File
@@ -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<string, (e: Event) => 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 <md-ripple>
* 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');
}
}
}
+342
View File
@@ -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');
(<any>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<any>;
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 = <HTMLElement>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 = <HTMLElement>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 =
<HTMLElement>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 = <HTMLElement>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 = <HTMLElement>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: `
<div id="container" md-ripple style="position: relative; width:300px; height:200px;">
</div>
`,
})
class BasicRippleContainer {
@ViewChild(MdRipple) ripple: MdRipple;
}
@Component({
directives: [MdRipple],
template: `
<div id="container" style="position: relative; width:300px; height:200px;"
md-ripple
[md-ripple-trigger]="trigger"
[md-ripple-centered]="centered"
[md-ripple-max-radius]="maxRadius"
[md-ripple-disabled]="disabled"
[md-ripple-color]="color"
[md-ripple-background-color]="backgroundColor">
</div>
<div class="alternateTrigger"></div>
`,
})
class RippleContainerWithInputBindings {
trigger: HTMLElement = null;
centered = false;
disabled = false;
maxRadius = 0;
color = '';
backgroundColor = '';
@ViewChild(MdRipple) ripple: MdRipple;
}
+168
View File
@@ -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<string, (e: Event) => 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];
+62
View File
@@ -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;
}
+2
View File
@@ -10,3 +10,5 @@
@include md-elevation($zValue);
}
}
@import 'ripple';
+1
View File
@@ -18,6 +18,7 @@
<a md-list-item [routerLink]="['progress-bar']">Progress Bar</a>
<a md-list-item [routerLink]="['progress-circle']">Progress Circle</a>
<a md-list-item [routerLink]="['radio']">Radio</a>
<a md-list-item [routerLink]="['ripple']">Ripple</a>
<a md-list-item [routerLink]="['sidenav']">Sidenav</a>
<a md-list-item [routerLink]="['slider']">Slider</a>
<a md-list-item [routerLink]="['slide-toggle']">Slide Toggle</a>
+2
View File
@@ -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},
];
+1
View File
@@ -14,6 +14,7 @@
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="core/style/core.css" rel="stylesheet">
<link href="main.css" rel="stylesheet">
<!-- FontAwesome for md-icon demo. -->
+52
View File
@@ -0,0 +1,52 @@
<!-- Use <md-card>? -->
<div class="demo-ripple">
<section>
<button md-button>flat</button>
<button md-raised-button>raised</button>
<button md-fab>
<md-icon>check</md-icon>
</button>
<button md-mini-fab>
<md-icon>check</md-icon>
</button>
</section>
<section>
<div class="demo-ripple-checkbox-option"><md-checkbox [(ngModel)]="centered">Centered</md-checkbox></div>
<div class="demo-ripple-checkbox-option"><md-checkbox [(ngModel)]="unbounded">Unbounded</md-checkbox></div>
<div class="demo-ripple-checkbox-option"><md-checkbox [(ngModel)]="disabled">Disabled</md-checkbox></div>
<div class="demo-ripple-checkbox-option"><md-checkbox [(ngModel)]="rounded">Rounded container (flaky in Firefox)</md-checkbox></div>
</section>
<section>
Speed
<md-radio-group [(ngModel)]="rippleSpeed">
<md-radio-button name="demo-ripple-options" value="0.1">Slow</md-radio-button>
<md-radio-button name="demo-ripple-options" value="1">Normal</md-radio-button>
<md-radio-button name="demo-ripple-options" value="4">Fast</md-radio-button>
</md-radio-group>
</section>
<section>
<md-input placeholder="Max radius" aria-label="radius" [(ngModel)]="maxRadius"></md-input>
<md-input placeholder="Ripple color" aria-label="color" [(ngModel)]="rippleColor"></md-input>
<md-input placeholder="Ripple background" aria-label="background" [(ngModel)]="rippleBackgroundColor"></md-input>
</section>
<section>
<button md-raised-button (click)="doManualRipple()">Manual ripple</button>
</section>
<section>
<div class="demo-ripple-container"
[class.demo-ripple-disabled]="disabled"
[class.demo-ripple-rounded]="rounded"
md-ripple
[md-ripple-centered]="centered"
[md-ripple-disabled]="disabled"
[md-ripple-unbounded]="unbounded"
[md-ripple-max-radius]="maxRadius"
[md-ripple-color]="rippleColor"
[md-ripple-background-color]="rippleBackgroundColor"
[md-ripple-speed-factor]="rippleSpeed">
Click me
</div>
</section>
{{centered.checked}} {{rounded.checked}}
</div>
+35
View File
@@ -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;
}
}
+50
View File
@@ -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);
}
}
}