1<!--
2@license
3Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
4This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
5The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
6The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
7Code distributed by Google as part of the polymer project is also
8subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
9-->
10
11<link rel="import" href="../polymer/polymer.html">
12<link rel="import" href="../iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
13
14<!--
15Material design: [Surface reaction](https://www.google.com/design/spec/animation/responsive-interaction.html#responsive-interaction-surface-reaction)
16
17`paper-ripple` provides a visual effect that other paper elements can
18use to simulate a rippling effect emanating from the point of contact.  The
19effect can be visualized as a concentric circle with motion.
20
21Example:
22
23    <div style="position:relative">
24      <paper-ripple></paper-ripple>
25    </div>
26
27Note, it's important that the parent container of the ripple be relative position, otherwise
28the ripple will emanate outside of the desired container.
29
30`paper-ripple` listens to "mousedown" and "mouseup" events so it would display ripple
31effect when touches on it.  You can also defeat the default behavior and
32manually route the down and up actions to the ripple element.  Note that it is
33important if you call `downAction()` you will have to make sure to call
34`upAction()` so that `paper-ripple` would end the animation loop.
35
36Example:
37
38    <paper-ripple id="ripple" style="pointer-events: none;"></paper-ripple>
39    ...
40    downAction: function(e) {
41      this.$.ripple.downAction({detail: {x: e.x, y: e.y}});
42    },
43    upAction: function(e) {
44      this.$.ripple.upAction();
45    }
46
47Styling ripple effect:
48
49  Use CSS color property to style the ripple:
50
51    paper-ripple {
52      color: #4285f4;
53    }
54
55  Note that CSS color property is inherited so it is not required to set it on
56  the `paper-ripple` element directly.
57
58By default, the ripple is centered on the point of contact.  Apply the `recenters`
59attribute to have the ripple grow toward the center of its container.
60
61    <paper-ripple recenters></paper-ripple>
62
63You can also  center the ripple inside its container from the start.
64
65    <paper-ripple center></paper-ripple>
66
67Apply `circle` class to make the rippling effect within a circle.
68
69    <paper-ripple class="circle"></paper-ripple>
70
71@group Paper Elements
72@element paper-ripple
73@hero hero.svg
74@demo demo/index.html
75-->
76
77<dom-module id="paper-ripple">
78
79  <template>
80    <style>
81      :host {
82        display: block;
83        position: absolute;
84        border-radius: inherit;
85        overflow: hidden;
86        top: 0;
87        left: 0;
88        right: 0;
89        bottom: 0;
90
91        /* See PolymerElements/paper-behaviors/issues/34. On non-Chrome browsers,
92         * creating a node (with a position:absolute) in the middle of an event
93         * handler "interrupts" that event handler (which happens when the
94         * ripple is created on demand) */
95        pointer-events: none;
96      }
97
98      :host([animating]) {
99        /* This resolves a rendering issue in Chrome (as of 40) where the
100           ripple is not properly clipped by its parent (which may have
101           rounded corners). See: http://jsbin.com/temexa/4
102
103           Note: We only apply this style conditionally. Otherwise, the browser
104           will create a new compositing layer for every ripple element on the
105           page, and that would be bad. */
106        -webkit-transform: translate(0, 0);
107        transform: translate3d(0, 0, 0);
108      }
109
110      #background,
111      #waves,
112      .wave-container,
113      .wave {
114        pointer-events: none;
115        position: absolute;
116        top: 0;
117        left: 0;
118        width: 100%;
119        height: 100%;
120      }
121
122      #background,
123      .wave {
124        opacity: 0;
125      }
126
127      #waves,
128      .wave {
129        overflow: hidden;
130      }
131
132      .wave-container,
133      .wave {
134        border-radius: 50%;
135      }
136
137      :host(.circle) #background,
138      :host(.circle) #waves {
139        border-radius: 50%;
140      }
141
142      :host(.circle) .wave-container {
143        overflow: hidden;
144      }
145    </style>
146
147    <div id="background"></div>
148    <div id="waves"></div>
149  </template>
150</dom-module>
151<script>
152  (function() {
153    var Utility = {
154      distance: function(x1, y1, x2, y2) {
155        var xDelta = (x1 - x2);
156        var yDelta = (y1 - y2);
157
158        return Math.sqrt(xDelta * xDelta + yDelta * yDelta);
159      },
160
161      now: window.performance && window.performance.now ?
162          window.performance.now.bind(window.performance) : Date.now
163    };
164
165    /**
166     * @param {HTMLElement} element
167     * @constructor
168     */
169    function ElementMetrics(element) {
170      this.element = element;
171      this.width = this.boundingRect.width;
172      this.height = this.boundingRect.height;
173
174      this.size = Math.max(this.width, this.height);
175    }
176
177    ElementMetrics.prototype = {
178      get boundingRect () {
179        return this.element.getBoundingClientRect();
180      },
181
182      furthestCornerDistanceFrom: function(x, y) {
183        var topLeft = Utility.distance(x, y, 0, 0);
184        var topRight = Utility.distance(x, y, this.width, 0);
185        var bottomLeft = Utility.distance(x, y, 0, this.height);
186        var bottomRight = Utility.distance(x, y, this.width, this.height);
187
188        return Math.max(topLeft, topRight, bottomLeft, bottomRight);
189      }
190    };
191
192    /**
193     * @param {HTMLElement} element
194     * @constructor
195     */
196    function Ripple(element) {
197      this.element = element;
198      this.color = window.getComputedStyle(element).color;
199
200      this.wave = document.createElement('div');
201      this.waveContainer = document.createElement('div');
202      this.wave.style.backgroundColor = this.color;
203      this.wave.classList.add('wave');
204      this.waveContainer.classList.add('wave-container');
205      Polymer.dom(this.waveContainer).appendChild(this.wave);
206
207      this.resetInteractionState();
208    }
209
210    Ripple.MAX_RADIUS = 300;
211
212    Ripple.prototype = {
213      get recenters() {
214        return this.element.recenters;
215      },
216
217      get center() {
218        return this.element.center;
219      },
220
221      get mouseDownElapsed() {
222        var elapsed;
223
224        if (!this.mouseDownStart) {
225          return 0;
226        }
227
228        elapsed = Utility.now() - this.mouseDownStart;
229
230        if (this.mouseUpStart) {
231          elapsed -= this.mouseUpElapsed;
232        }
233
234        return elapsed;
235      },
236
237      get mouseUpElapsed() {
238        return this.mouseUpStart ?
239          Utility.now () - this.mouseUpStart : 0;
240      },
241
242      get mouseDownElapsedSeconds() {
243        return this.mouseDownElapsed / 1000;
244      },
245
246      get mouseUpElapsedSeconds() {
247        return this.mouseUpElapsed / 1000;
248      },
249
250      get mouseInteractionSeconds() {
251        return this.mouseDownElapsedSeconds + this.mouseUpElapsedSeconds;
252      },
253
254      get initialOpacity() {
255        return this.element.initialOpacity;
256      },
257
258      get opacityDecayVelocity() {
259        return this.element.opacityDecayVelocity;
260      },
261
262      get radius() {
263        var width2 = this.containerMetrics.width * this.containerMetrics.width;
264        var height2 = this.containerMetrics.height * this.containerMetrics.height;
265        var waveRadius = Math.min(
266          Math.sqrt(width2 + height2),
267          Ripple.MAX_RADIUS
268        ) * 1.1 + 5;
269
270        var duration = 1.1 - 0.2 * (waveRadius / Ripple.MAX_RADIUS);
271        var timeNow = this.mouseInteractionSeconds / duration;
272        var size = waveRadius * (1 - Math.pow(80, -timeNow));
273
274        return Math.abs(size);
275      },
276
277      get opacity() {
278        if (!this.mouseUpStart) {
279          return this.initialOpacity;
280        }
281
282        return Math.max(
283          0,
284          this.initialOpacity - this.mouseUpElapsedSeconds * this.opacityDecayVelocity
285        );
286      },
287
288      get outerOpacity() {
289        // Linear increase in background opacity, capped at the opacity
290        // of the wavefront (waveOpacity).
291        var outerOpacity = this.mouseUpElapsedSeconds * 0.3;
292        var waveOpacity = this.opacity;
293
294        return Math.max(
295          0,
296          Math.min(outerOpacity, waveOpacity)
297        );
298      },
299
300      get isOpacityFullyDecayed() {
301        return this.opacity < 0.01 &&
302          this.radius >= Math.min(this.maxRadius, Ripple.MAX_RADIUS);
303      },
304
305      get isRestingAtMaxRadius() {
306        return this.opacity >= this.initialOpacity &&
307          this.radius >= Math.min(this.maxRadius, Ripple.MAX_RADIUS);
308      },
309
310      get isAnimationComplete() {
311        return this.mouseUpStart ?
312          this.isOpacityFullyDecayed : this.isRestingAtMaxRadius;
313      },
314
315      get translationFraction() {
316        return Math.min(
317          1,
318          this.radius / this.containerMetrics.size * 2 / Math.sqrt(2)
319        );
320      },
321
322      get xNow() {
323        if (this.xEnd) {
324          return this.xStart + this.translationFraction * (this.xEnd - this.xStart);
325        }
326
327        return this.xStart;
328      },
329
330      get yNow() {
331        if (this.yEnd) {
332          return this.yStart + this.translationFraction * (this.yEnd - this.yStart);
333        }
334
335        return this.yStart;
336      },
337
338      get isMouseDown() {
339        return this.mouseDownStart && !this.mouseUpStart;
340      },
341
342      resetInteractionState: function() {
343        this.maxRadius = 0;
344        this.mouseDownStart = 0;
345        this.mouseUpStart = 0;
346
347        this.xStart = 0;
348        this.yStart = 0;
349        this.xEnd = 0;
350        this.yEnd = 0;
351        this.slideDistance = 0;
352
353        this.containerMetrics = new ElementMetrics(this.element);
354      },
355
356      draw: function() {
357        var scale;
358        var translateString;
359        var dx;
360        var dy;
361
362        this.wave.style.opacity = this.opacity;
363
364        scale = this.radius / (this.containerMetrics.size / 2);
365        dx = this.xNow - (this.containerMetrics.width / 2);
366        dy = this.yNow - (this.containerMetrics.height / 2);
367
368
369        // 2d transform for safari because of border-radius and overflow:hidden clipping bug.
370        // https://bugs.webkit.org/show_bug.cgi?id=98538
371        this.waveContainer.style.webkitTransform = 'translate(' + dx + 'px, ' + dy + 'px)';
372        this.waveContainer.style.transform = 'translate3d(' + dx + 'px, ' + dy + 'px, 0)';
373        this.wave.style.webkitTransform = 'scale(' + scale + ',' + scale + ')';
374        this.wave.style.transform = 'scale3d(' + scale + ',' + scale + ',1)';
375      },
376
377      /** @param {Event=} event */
378      downAction: function(event) {
379        var xCenter = this.containerMetrics.width / 2;
380        var yCenter = this.containerMetrics.height / 2;
381
382        this.resetInteractionState();
383        this.mouseDownStart = Utility.now();
384
385        if (this.center) {
386          this.xStart = xCenter;
387          this.yStart = yCenter;
388          this.slideDistance = Utility.distance(
389            this.xStart, this.yStart, this.xEnd, this.yEnd
390          );
391        } else {
392          this.xStart = event ?
393              event.detail.x - this.containerMetrics.boundingRect.left :
394              this.containerMetrics.width / 2;
395          this.yStart = event ?
396              event.detail.y - this.containerMetrics.boundingRect.top :
397              this.containerMetrics.height / 2;
398        }
399
400        if (this.recenters) {
401          this.xEnd = xCenter;
402          this.yEnd = yCenter;
403          this.slideDistance = Utility.distance(
404            this.xStart, this.yStart, this.xEnd, this.yEnd
405          );
406        }
407
408        this.maxRadius = this.containerMetrics.furthestCornerDistanceFrom(
409          this.xStart,
410          this.yStart
411        );
412
413        this.waveContainer.style.top =
414          (this.containerMetrics.height - this.containerMetrics.size) / 2 + 'px';
415        this.waveContainer.style.left =
416          (this.containerMetrics.width - this.containerMetrics.size) / 2 + 'px';
417
418        this.waveContainer.style.width = this.containerMetrics.size + 'px';
419        this.waveContainer.style.height = this.containerMetrics.size + 'px';
420      },
421
422      /** @param {Event=} event */
423      upAction: function(event) {
424        if (!this.isMouseDown) {
425          return;
426        }
427
428        this.mouseUpStart = Utility.now();
429      },
430
431      remove: function() {
432        Polymer.dom(this.waveContainer.parentNode).removeChild(
433          this.waveContainer
434        );
435      }
436    };
437
438    Polymer({
439      is: 'paper-ripple',
440
441      behaviors: [
442        Polymer.IronA11yKeysBehavior
443      ],
444
445      properties: {
446        /**
447         * The initial opacity set on the wave.
448         *
449         * @attribute initialOpacity
450         * @type number
451         * @default 0.25
452         */
453        initialOpacity: {
454          type: Number,
455          value: 0.25
456        },
457
458        /**
459         * How fast (opacity per second) the wave fades out.
460         *
461         * @attribute opacityDecayVelocity
462         * @type number
463         * @default 0.8
464         */
465        opacityDecayVelocity: {
466          type: Number,
467          value: 0.8
468        },
469
470        /**
471         * If true, ripples will exhibit a gravitational pull towards
472         * the center of their container as they fade away.
473         *
474         * @attribute recenters
475         * @type boolean
476         * @default false
477         */
478        recenters: {
479          type: Boolean,
480          value: false
481        },
482
483        /**
484         * If true, ripples will center inside its container
485         *
486         * @attribute recenters
487         * @type boolean
488         * @default false
489         */
490        center: {
491          type: Boolean,
492          value: false
493        },
494
495        /**
496         * A list of the visual ripples.
497         *
498         * @attribute ripples
499         * @type Array
500         * @default []
501         */
502        ripples: {
503          type: Array,
504          value: function() {
505            return [];
506          }
507        },
508
509        /**
510         * True when there are visible ripples animating within the
511         * element.
512         */
513        animating: {
514          type: Boolean,
515          readOnly: true,
516          reflectToAttribute: true,
517          value: false
518        },
519
520        /**
521         * If true, the ripple will remain in the "down" state until `holdDown`
522         * is set to false again.
523         */
524        holdDown: {
525          type: Boolean,
526          value: false,
527          observer: '_holdDownChanged'
528        },
529
530        /**
531         * If true, the ripple will not generate a ripple effect
532         * via pointer interaction.
533         * Calling ripple's imperative api like `simulatedRipple` will
534         * still generate the ripple effect.
535         */
536        noink: {
537          type: Boolean,
538          value: false
539        },
540
541        _animating: {
542          type: Boolean
543        },
544
545        _boundAnimate: {
546          type: Function,
547          value: function() {
548            return this.animate.bind(this);
549          }
550        }
551      },
552
553      get target () {
554        return this.keyEventTarget;
555      },
556
557      keyBindings: {
558        'enter:keydown': '_onEnterKeydown',
559        'space:keydown': '_onSpaceKeydown',
560        'space:keyup': '_onSpaceKeyup'
561      },
562
563      attached: function() {
564        // Set up a11yKeysBehavior to listen to key events on the target,
565        // so that space and enter activate the ripple even if the target doesn't
566        // handle key events. The key handlers deal with `noink` themselves.
567        if (this.parentNode.nodeType == 11) { // DOCUMENT_FRAGMENT_NODE
568          this.keyEventTarget = Polymer.dom(this).getOwnerRoot().host;
569        } else {
570          this.keyEventTarget = this.parentNode;
571        }
572        var keyEventTarget = /** @type {!EventTarget} */ (this.keyEventTarget);
573        this.listen(keyEventTarget, 'up', 'uiUpAction');
574        this.listen(keyEventTarget, 'down', 'uiDownAction');
575      },
576
577      detached: function() {
578        this.unlisten(this.keyEventTarget, 'up', 'uiUpAction');
579        this.unlisten(this.keyEventTarget, 'down', 'uiDownAction');
580        this.keyEventTarget = null;
581      },
582
583      get shouldKeepAnimating () {
584        for (var index = 0; index < this.ripples.length; ++index) {
585          if (!this.ripples[index].isAnimationComplete) {
586            return true;
587          }
588        }
589
590        return false;
591      },
592
593      simulatedRipple: function() {
594        this.downAction(null);
595
596        // Please see polymer/polymer#1305
597        this.async(function() {
598          this.upAction();
599        }, 1);
600      },
601
602      /**
603       * Provokes a ripple down effect via a UI event,
604       * respecting the `noink` property.
605       * @param {Event=} event
606       */
607      uiDownAction: function(event) {
608        if (!this.noink) {
609          this.downAction(event);
610        }
611      },
612
613      /**
614       * Provokes a ripple down effect via a UI event,
615       * *not* respecting the `noink` property.
616       * @param {Event=} event
617       */
618      downAction: function(event) {
619        if (this.holdDown && this.ripples.length > 0) {
620          return;
621        }
622
623        var ripple = this.addRipple();
624
625        ripple.downAction(event);
626
627        if (!this._animating) {
628          this._animating = true;
629          this.animate();
630        }
631      },
632
633      /**
634       * Provokes a ripple up effect via a UI event,
635       * respecting the `noink` property.
636       * @param {Event=} event
637       */
638      uiUpAction: function(event) {
639        if (!this.noink) {
640          this.upAction(event);
641        }
642      },
643
644      /**
645       * Provokes a ripple up effect via a UI event,
646       * *not* respecting the `noink` property.
647       * @param {Event=} event
648       */
649      upAction: function(event) {
650        if (this.holdDown) {
651          return;
652        }
653
654        this.ripples.forEach(function(ripple) {
655          ripple.upAction(event);
656        });
657
658        this._animating = true;
659        this.animate();
660      },
661
662      onAnimationComplete: function() {
663        this._animating = false;
664        this.$.background.style.backgroundColor = null;
665        this.fire('transitionend');
666      },
667
668      addRipple: function() {
669        var ripple = new Ripple(this);
670
671        Polymer.dom(this.$.waves).appendChild(ripple.waveContainer);
672        this.$.background.style.backgroundColor = ripple.color;
673        this.ripples.push(ripple);
674
675        this._setAnimating(true);
676
677        return ripple;
678      },
679
680      removeRipple: function(ripple) {
681        var rippleIndex = this.ripples.indexOf(ripple);
682
683        if (rippleIndex < 0) {
684          return;
685        }
686
687        this.ripples.splice(rippleIndex, 1);
688
689        ripple.remove();
690
691        if (!this.ripples.length) {
692          this._setAnimating(false);
693        }
694      },
695
696      /**
697       * This conflicts with Element#antimate().
698       * https://developer.mozilla.org/en-US/docs/Web/API/Element/animate
699       * @suppress {checkTypes}
700       */
701      animate: function() {
702        if (!this._animating) {
703          return;
704        }
705        var index;
706        var ripple;
707
708        for (index = 0; index < this.ripples.length; ++index) {
709          ripple = this.ripples[index];
710
711          ripple.draw();
712
713          this.$.background.style.opacity = ripple.outerOpacity;
714
715          if (ripple.isOpacityFullyDecayed && !ripple.isRestingAtMaxRadius) {
716            this.removeRipple(ripple);
717          }
718        }
719
720        if (!this.shouldKeepAnimating && this.ripples.length === 0) {
721          this.onAnimationComplete();
722        } else {
723          window.requestAnimationFrame(this._boundAnimate);
724        }
725      },
726
727      _onEnterKeydown: function() {
728        this.uiDownAction();
729        this.async(this.uiUpAction, 1);
730      },
731
732      _onSpaceKeydown: function() {
733        this.uiDownAction();
734      },
735
736      _onSpaceKeyup: function() {
737        this.uiUpAction();
738      },
739
740      // note: holdDown does not respect noink since it can be a focus based
741      // effect.
742      _holdDownChanged: function(newVal, oldVal) {
743        if (oldVal === undefined) {
744          return;
745        }
746        if (newVal) {
747          this.downAction();
748        } else {
749          this.upAction();
750        }
751      }
752
753      /**
754      Fired when the animation finishes.
755      This is useful if you want to wait until
756      the ripple animation finishes to perform some action.
757
758      @event transitionend
759      @param {{node: Object}} detail Contains the animated node.
760      */
761    });
762  })();
763</script>
764