mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
@@ -30,6 +30,9 @@ export {
|
||||
// Gestures
|
||||
export {MdGestureConfig} from './gestures/MdGestureConfig';
|
||||
|
||||
// Ripple
|
||||
export {MD_RIPPLE_DIRECTIVES, MdRipple} from './ripple/ripple';
|
||||
|
||||
// a11y
|
||||
export {
|
||||
AriaLivePoliteness,
|
||||
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,3 +10,5 @@
|
||||
@include md-elevation($zValue);
|
||||
}
|
||||
}
|
||||
|
||||
@import 'ripple';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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},
|
||||
];
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user