1// Copyright (c) 2012 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * @fileoverview Card slider implementation. Allows you to create interactions
7 * that have items that can slide left to right to reveal additional items.
8 * Works by adding the necessary event handlers to a specific DOM structure
9 * including a frame, container and cards.
10 * - The frame defines the boundary of one item. Each card will be expanded to
11 *   fill the width of the frame. This element is also overflow hidden so that
12 *   the additional items left / right do not trigger horizontal scrolling.
13 * - The container is what all the touch events are attached to. This element
14 *   will be expanded to be the width of all cards.
15 * - The cards are the individual viewable items. There should be one card for
16 *   each item in the list. Only one card will be visible at a time. Two cards
17 *   will be visible while you are transitioning between cards.
18 *
19 * This class is designed to work well on any hardware-accelerated touch device.
20 * It should still work on pre-hardware accelerated devices it just won't feel
21 * very good. It should also work well with a mouse.
22 */
23
24// Use an anonymous function to enable strict mode just for this file (which
25// will be concatenated with other files when embedded in Chrome
26cr.define('cr.ui', function() {
27  'use strict';
28
29  /**
30   * @constructor
31   * @param {!Element} frame The bounding rectangle that cards are visible in.
32   * @param {!Element} container The surrounding element that will have event
33   *     listeners attached to it.
34   * @param {number} cardWidth The width of each card should have.
35   */
36  function CardSlider(frame, container, cardWidth) {
37    /**
38     * @type {!Element}
39     * @private
40     */
41    this.frame_ = frame;
42
43    /**
44     * @type {!Element}
45     * @private
46     */
47    this.container_ = container;
48
49    /**
50     * Array of card elements.
51     * @type {!Array.<!Element>}
52     * @private
53     */
54    this.cards_ = [];
55
56    /**
57     * Index of currently shown card.
58     * @type {number}
59     * @private
60     */
61    this.currentCard_ = -1;
62
63    /**
64     * @type {number}
65     * @private
66     */
67    this.cardWidth_ = cardWidth;
68
69    /**
70     * @type {!cr.ui.TouchHandler}
71     * @private
72     */
73    this.touchHandler_ = new cr.ui.TouchHandler(this.container_);
74  }
75
76
77  /**
78   * The time to transition between cards when animating. Measured in ms.
79   * @type {number}
80   * @private
81   * @const
82   */
83  CardSlider.TRANSITION_TIME_ = 200;
84
85
86  /**
87   * The minimum velocity required to transition cards if they did not drag past
88   * the halfway point between cards. Measured in pixels / ms.
89   * @type {number}
90   * @private
91   * @const
92   */
93  CardSlider.TRANSITION_VELOCITY_THRESHOLD_ = 0.2;
94
95
96  CardSlider.prototype = {
97    /**
98     * The current left offset of the container relative to the frame. This
99     * position does not include deltas from active drag operations, and
100     * always aligns with a frame boundary.
101     * @type {number}
102     * @private
103     */
104    currentLeft_: 0,
105
106    /**
107     * Current offset relative to |currentLeft_| due to an active drag
108     * operation.
109     * @type {number}
110     * @private
111     */
112    deltaX_: 0,
113
114    /**
115     * Initialize all elements and event handlers. Must call after construction
116     * and before usage.
117     * @param {boolean} ignoreMouseWheelEvents If true, horizontal mouse wheel
118     *     events will be ignored, rather than flipping between pages.
119     */
120    initialize: function(ignoreMouseWheelEvents) {
121      var view = this.container_.ownerDocument.defaultView;
122      assert(view.getComputedStyle(this.container_).display == '-webkit-box',
123          'Container should be display -webkit-box.');
124      assert(view.getComputedStyle(this.frame_).overflow == 'hidden',
125          'Frame should be overflow hidden.');
126      assert(view.getComputedStyle(this.container_).position == 'static',
127          'Container should be position static.');
128
129      this.updateCardWidths_();
130
131      this.mouseWheelScrollAmount_ = 0;
132      this.mouseWheelCardSelected_ = false;
133      this.mouseWheelIsContinuous_ = false;
134      this.scrollClearTimeout_ = null;
135      if (!ignoreMouseWheelEvents) {
136        this.frame_.addEventListener('mousewheel',
137                                     this.onMouseWheel_.bind(this));
138      }
139      this.container_.addEventListener(
140          'webkitTransitionEnd', this.onWebkitTransitionEnd_.bind(this));
141
142      // Also support touch events in case a touch screen happens to be
143      // available.  Note that this has minimal impact in the common case of
144      // no touch events (eg. we're mainly just adding listeners for events that
145      // will never trigger).
146      var TouchHandler = cr.ui.TouchHandler;
147      this.container_.addEventListener(TouchHandler.EventType.TOUCH_START,
148                                       this.onTouchStart_.bind(this));
149      this.container_.addEventListener(TouchHandler.EventType.DRAG_START,
150                                       this.onDragStart_.bind(this));
151      this.container_.addEventListener(TouchHandler.EventType.DRAG_MOVE,
152                                       this.onDragMove_.bind(this));
153      this.container_.addEventListener(TouchHandler.EventType.DRAG_END,
154                                       this.onDragEnd_.bind(this));
155
156      this.touchHandler_.enable(/* opt_capture */ false);
157    },
158
159    /**
160     * Use in cases where the width of the frame has changed in order to update
161     * the width of cards. For example should be used when orientation changes
162     * in full width sliders.
163     * @param {number} newCardWidth Width all cards should have, in pixels.
164     */
165    resize: function(newCardWidth) {
166      if (newCardWidth != this.cardWidth_) {
167        this.cardWidth_ = newCardWidth;
168
169        this.updateCardWidths_();
170
171        // Must upate the transform on the container to show the correct card.
172        this.transformToCurrentCard_();
173      }
174    },
175
176    /**
177     * Sets the cards used. Can be called more than once to switch card sets.
178     * @param {!Array.<!Element>} cards The individual viewable cards.
179     * @param {number} index Index of the card to in the new set of cards to
180     *     navigate to.
181     */
182    setCards: function(cards, index) {
183      assert(index >= 0 && index < cards.length,
184          'Invalid index in CardSlider#setCards');
185      this.cards_ = cards;
186
187      this.updateCardWidths_();
188      this.updateSelectedCardAttributes_();
189
190      // Jump to the given card index.
191      this.selectCard(index, false, false, true);
192    },
193
194    /**
195     * Ensures that for all cards:
196     * - if the card is the current card, then it has 'selected-card' in its
197     *   classList, and is visible for accessibility
198     * - if the card is not the selected card, then it does not have
199     *   'selected-card' in its classList, and is invisible for accessibility.
200     * @private
201     */
202    updateSelectedCardAttributes_: function() {
203      for (var i = 0; i < this.cards_.length; i++) {
204        if (i == this.currentCard_) {
205          this.cards_[i].classList.add('selected-card');
206          this.cards_[i].removeAttribute('aria-hidden');
207        } else {
208          this.cards_[i].classList.remove('selected-card');
209          this.cards_[i].setAttribute('aria-hidden', true);
210        }
211      }
212    },
213
214    /**
215     * Updates the width of each card.
216     * @private
217     */
218    updateCardWidths_: function() {
219      for (var i = 0, card; card = this.cards_[i]; i++)
220        card.style.width = this.cardWidth_ + 'px';
221    },
222
223    /**
224     * Returns the index of the current card.
225     * @return {number} index of the current card.
226     */
227    get currentCard() {
228      return this.currentCard_;
229    },
230
231    /**
232     * Allows setting the current card index.
233     * @param {number} index A new index to set the current index to.
234     * @return {number} The new index after having been set.
235     */
236    set currentCard(index) {
237      return (this.currentCard_ = index);
238    },
239
240    /**
241     * Returns the number of cards.
242     * @return {number} number of cards.
243     */
244    get cardCount() {
245      return this.cards_.length;
246    },
247
248    /**
249     * Returns the current card itself.
250     * @return {!Element} the currently shown card.
251     */
252    get currentCardValue() {
253      return this.cards_[this.currentCard_];
254    },
255
256    /**
257     * Returns the frame holding the cards.
258     * @return {Element} The frame used to position the cards.
259     */
260    get frame() {
261      return this.frame_;
262    },
263
264    /**
265     * Handle horizontal scrolls to flip between pages.
266     * @private
267     */
268    onMouseWheel_: function(e) {
269      if (e.wheelDeltaX == 0)
270        return;
271
272      // Continuous devices such as an Apple Touchpad or Apple MagicMouse will
273      // send arbitrary delta values. Conversly, standard mousewheels will
274      // send delta values in increments of 120.  (There is of course a small
275      // chance we mistake a continuous device for a non-continuous device.
276      // Unfortunately there isn't a better way to do this until real touch
277      // events are available to desktop clients.)
278      var DISCRETE_DELTA = 120;
279      if (e.wheelDeltaX % DISCRETE_DELTA)
280        this.mouseWheelIsContinuous_ = true;
281
282      if (this.mouseWheelIsContinuous_) {
283        // For continuous devices, detect a page swipe when the accumulated
284        // delta matches a pre-defined threshhold.  After changing the page,
285        // ignore wheel events for a short time before repeating this process.
286        if (this.mouseWheelCardSelected_) return;
287        this.mouseWheelScrollAmount_ += e.wheelDeltaX;
288        if (Math.abs(this.mouseWheelScrollAmount_) >= 600) {
289          var pagesToScroll = this.mouseWheelScrollAmount_ > 0 ? 1 : -1;
290          if (!isRTL())
291            pagesToScroll *= -1;
292          var newCardIndex = this.currentCard + pagesToScroll;
293          newCardIndex = Math.min(this.cards_.length - 1,
294                                  Math.max(0, newCardIndex));
295          this.selectCard(newCardIndex, true);
296          this.mouseWheelCardSelected_ = true;
297        }
298      } else {
299        // For discrete devices, consider each wheel tick a page change.
300        var pagesToScroll = e.wheelDeltaX / DISCRETE_DELTA;
301        if (!isRTL())
302          pagesToScroll *= -1;
303        var newCardIndex = this.currentCard + pagesToScroll;
304        newCardIndex = Math.min(this.cards_.length - 1,
305                                Math.max(0, newCardIndex));
306        this.selectCard(newCardIndex, true);
307      }
308
309      // We got a mouse wheel event, so cancel any pending scroll wheel timeout.
310      if (this.scrollClearTimeout_ != null)
311        clearTimeout(this.scrollClearTimeout_);
312      // If we didn't use up all the scroll, hold onto it for a little bit, but
313      // drop it after a delay.
314      if (this.mouseWheelScrollAmount_ != 0) {
315        this.scrollClearTimeout_ =
316            setTimeout(this.clearMouseWheelScroll_.bind(this), 500);
317      }
318    },
319
320    /**
321     * Resets the amount of horizontal scroll we've seen to 0. See
322     * onMouseWheel_.
323     * @private
324     */
325    clearMouseWheelScroll_: function() {
326      this.mouseWheelScrollAmount_ = 0;
327      this.mouseWheelCardSelected_ = false;
328    },
329
330    /**
331     * Handles the ends of -webkit-transitions on -webkit-transform (animated
332     * card switches).
333     * @param {Event} e The webkitTransitionEnd event.
334     * @private
335     */
336    onWebkitTransitionEnd_: function(e) {
337      // Ignore irrelevant transitions that might bubble up.
338      if (e.target !== this.container_ ||
339          e.propertyName != '-webkit-transform') {
340        return;
341      }
342      this.fireChangeEndedEvent_(true);
343    },
344
345    /**
346     * Dispatches a simple event to tell subscribers we're done moving to the
347     * newly selected card.
348     * @param {boolean} wasAnimated whether or not the change was animated.
349     * @private
350     */
351    fireChangeEndedEvent_: function(wasAnimated) {
352      var e = document.createEvent('Event');
353      e.initEvent('cardSlider:card_change_ended', true, true);
354      e.cardSlider = this;
355      e.changedTo = this.currentCard_;
356      e.wasAnimated = wasAnimated;
357      this.container_.dispatchEvent(e);
358    },
359
360    /**
361     * Add a card to the card slider at a particular index. If the card being
362     * added is inserted in front of the current card, cardSlider.currentCard
363     * will be adjusted accordingly (to current card + 1).
364     * @param {!Node} card A card that will be added to the card slider.
365     * @param {number} index An index at which the given |card| should be
366     *     inserted. Must be positive and less than the number of cards.
367     */
368    addCardAtIndex: function(card, index) {
369      assert(card instanceof Node, '|card| isn\'t a Node');
370      this.assertValidIndex_(index);
371      this.cards_ = Array.prototype.concat.call(
372          this.cards_.slice(0, index), card, this.cards_.slice(index));
373
374      this.updateSelectedCardAttributes_();
375
376      if (this.currentCard_ == -1)
377        this.currentCard_ = 0;
378      else if (index <= this.currentCard_)
379        this.selectCard(this.currentCard_ + 1, false, true, true);
380
381      this.fireAddedEvent_(card, index);
382    },
383
384    /**
385     * Append a card to the end of the list.
386     * @param {!Node} card A card to add at the end of the card slider.
387     */
388    appendCard: function(card) {
389      assert(card instanceof Node, '|card| isn\'t a Node');
390      this.cards_.push(card);
391      this.fireAddedEvent_(card, this.cards_.length - 1);
392    },
393
394    /**
395     * Dispatches a simple event to tell interested subscribers that a card was
396     * added to this card slider.
397     * @param {Node} card The recently added card.
398     * @param {number} index The position of the newly added card.
399     * @private
400     */
401    fireAddedEvent_: function(card, index) {
402      this.assertValidIndex_(index);
403      var e = document.createEvent('Event');
404      e.initEvent('cardSlider:card_added', true, true);
405      e.addedIndex = index;
406      e.addedCard = card;
407      this.container_.dispatchEvent(e);
408    },
409
410    /**
411     * Returns the card at a particular index.
412     * @param {number} index The index of the card to return.
413     * @return {!Element} The card at the given index.
414     */
415    getCardAtIndex: function(index) {
416      this.assertValidIndex_(index);
417      return this.cards_[index];
418    },
419
420    /**
421     * Removes a card by index from the card slider. If the card to be removed
422     * is the current card or in front of the current card, the current card
423     * will be updated (to current card - 1).
424     * @param {!Node} card A card to be removed.
425     */
426    removeCard: function(card) {
427      assert(card instanceof Node, '|card| isn\'t a Node');
428      this.removeCardAtIndex(this.cards_.indexOf(card));
429    },
430
431    /**
432     * Removes a card by index from the card slider. If the card to be removed
433     * is the current card or in front of the current card, the current card
434     * will be updated (to current card - 1).
435     * @param {number} index The index of the tile that should be removed.
436     */
437    removeCardAtIndex: function(index) {
438      this.assertValidIndex_(index);
439      var removed = this.cards_.splice(index, 1).pop();
440
441      if (this.cards_.length == 0)
442        this.currentCard_ = -1;
443      else if (index < this.currentCard_)
444        this.selectCard(this.currentCard_ - 1, false, true);
445
446      this.fireRemovedEvent_(removed, index);
447    },
448
449    /**
450     * Dispatches a cardSlider:card_removed event so interested subscribers know
451     * when a card was removed from this card slider.
452     * @param {Node} card The recently removed card.
453     * @param {number} index The index of the card before it was removed.
454     * @private
455     */
456    fireRemovedEvent_: function(card, index) {
457      var e = document.createEvent('Event');
458      e.initEvent('cardSlider:card_removed', true, true);
459      e.removedCard = card;
460      e.removedIndex = index;
461      this.container_.dispatchEvent(e);
462    },
463
464    /**
465     * This re-syncs the -webkit-transform that's used to position the frame in
466     * the likely event it needs to be updated by a card being inserted or
467     * removed in the flow.
468     */
469    repositionFrame: function() {
470      this.transformToCurrentCard_();
471    },
472
473    /**
474     * Checks the the given |index| exists in this.cards_.
475     * @param {number} index An index to check.
476     * @private
477     */
478    assertValidIndex_: function(index) {
479      assert(index >= 0 && index < this.cards_.length);
480    },
481
482    /**
483     * Selects a new card, ensuring that it is a valid index, transforming the
484     * view and possibly calling the change card callback.
485     * @param {number} newCardIndex Index of card to show.
486     * @param {boolean=} opt_animate If true will animate transition from
487     *     current position to new position.
488     * @param {boolean=} opt_dontNotify If true, don't tell subscribers that
489     *     we've changed cards.
490     * @param {boolean=} opt_forceChange If true, ignore if the card already
491     *     selected.
492     */
493    selectCard: function(newCardIndex,
494                         opt_animate,
495                         opt_dontNotify,
496                         opt_forceChange) {
497      this.assertValidIndex_(newCardIndex);
498
499      var previousCard = this.currentCardValue;
500      var isChangingCard =
501          !this.cards_[newCardIndex].classList.contains('selected-card');
502
503      if (typeof opt_forceChange != 'undefined' && opt_forceChange)
504        isChangingCard = true;
505
506      if (isChangingCard) {
507        this.currentCard_ = newCardIndex;
508        this.updateSelectedCardAttributes_();
509      }
510
511      var willTransitionHappen = this.transformToCurrentCard_(opt_animate);
512
513      if (isChangingCard && !opt_dontNotify) {
514        var event = document.createEvent('Event');
515        event.initEvent('cardSlider:card_changed', true, true);
516        event.cardSlider = this;
517        event.wasAnimated = !!opt_animate;
518        this.container_.dispatchEvent(event);
519
520        // We also dispatch an event on the cards themselves.
521        if (previousCard) {
522          cr.dispatchSimpleEvent(previousCard, 'carddeselected',
523                                 true, true);
524        }
525        cr.dispatchSimpleEvent(this.currentCardValue, 'cardselected',
526                               true, true);
527      }
528
529      // If we're not changing, animated, or transitioning, fire a
530      // cardSlider:card_change_ended event right away.
531      if ((!isChangingCard || !opt_animate || !willTransitionHappen) &&
532          !opt_dontNotify) {
533        this.fireChangeEndedEvent_(false);
534      }
535    },
536
537    /**
538     * Selects a card from the stack. Passes through to selectCard.
539     * @param {Node} newCard The card that should be selected.
540     * @param {boolean=} opt_animate Whether to animate.
541     */
542    selectCardByValue: function(newCard, opt_animate) {
543      var i = this.cards_.indexOf(newCard);
544      assert(i != -1);
545      this.selectCard(i, opt_animate);
546    },
547
548    /**
549     * Centers the view on the card denoted by this.currentCard. Can either
550     * animate to that card or snap to it.
551     * @param {boolean=} opt_animate If true will animate transition from
552     *     current position to new position.
553     * @return {boolean} Whether or not a transformation was necessary.
554     * @private
555     */
556    transformToCurrentCard_: function(opt_animate) {
557      var prevLeft = this.currentLeft_;
558      this.currentLeft_ = -this.cardWidth_ *
559          (isRTL() ? this.cards_.length - this.currentCard - 1 :
560                     this.currentCard);
561
562      // If there's no change, return something to let the caller know there
563      // won't be a transition occuring.
564      if (prevLeft == this.currentLeft_ && this.deltaX_ == 0)
565        return false;
566
567      // Animate to the current card, which will either transition if the
568      // current card is new, or reset the existing card if we didn't drag
569      // enough to change cards.
570      var transition = '';
571      if (opt_animate) {
572        transition = '-webkit-transform ' + CardSlider.TRANSITION_TIME_ +
573                     'ms ease-in-out';
574      }
575      this.container_.style.WebkitTransition = transition;
576      this.translateTo_(this.currentLeft_);
577
578      return true;
579    },
580
581    /**
582     * Moves the view to the specified position.
583     * @param {number} x Horizontal position to move to.
584     * @private
585     */
586    translateTo_: function(x) {
587      // We use a webkitTransform to slide because this is GPU accelerated on
588      // Chrome and iOS.  Once Chrome does GPU acceleration on the position
589      // fixed-layout elements we could simply set the element's position to
590      // fixed and modify 'left' instead.
591      this.deltaX_ = x - this.currentLeft_;
592      this.container_.style.WebkitTransform = 'translate3d(' + x + 'px, 0, 0)';
593    },
594
595    /* Touch ******************************************************************/
596
597    /**
598     * Clear any transition that is in progress and enable dragging for the
599     * touch.
600     * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event.
601     * @private
602     */
603    onTouchStart_: function(e) {
604      this.container_.style.WebkitTransition = '';
605      e.enableDrag = true;
606    },
607
608    /**
609     * Tell the TouchHandler that dragging is acceptable when the user begins by
610     * scrolling horizontally and there is more than one card to slide.
611     * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event.
612     * @private
613     */
614    onDragStart_: function(e) {
615      e.enableDrag = this.cardCount > 1 && Math.abs(e.dragDeltaX) >
616          Math.abs(e.dragDeltaY);
617    },
618
619    /**
620     * On each drag move event reposition the container appropriately so the
621     * cards look like they are sliding.
622     * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event.
623     * @private
624     */
625    onDragMove_: function(e) {
626      var deltaX = e.dragDeltaX;
627      // If dragging beyond the first or last card then apply a backoff so the
628      // dragging feels stickier than usual.
629      if (!this.currentCard && deltaX > 0 ||
630          this.currentCard == (this.cards_.length - 1) && deltaX < 0) {
631        deltaX /= 2;
632      }
633      this.translateTo_(this.currentLeft_ + deltaX);
634    },
635
636    /**
637     * On drag end events we may want to transition to another card, depending
638     * on the ending position of the drag and the velocity of the drag.
639     * @param {!cr.ui.TouchHandler.Event} e The TouchHandler event.
640     * @private
641     */
642    onDragEnd_: function(e) {
643      var deltaX = e.dragDeltaX;
644      var velocity = this.touchHandler_.getEndVelocity().x;
645      var newX = this.currentLeft_ + deltaX;
646      var newCardIndex = Math.round(-newX / this.cardWidth_);
647
648      if (newCardIndex == this.currentCard && Math.abs(velocity) >
649          CardSlider.TRANSITION_VELOCITY_THRESHOLD_) {
650        // The drag wasn't far enough to change cards but the velocity was
651        // high enough to transition anyways. If the velocity is to the left
652        // (negative) then the user wishes to go right (card + 1).
653        newCardIndex += velocity > 0 ? -1 : 1;
654      }
655      // Ensure that the new card index is valid.  The new card index could be
656      // invalid if a swipe suggests scrolling off the end of the list of
657      // cards.
658      if (newCardIndex < 0)
659        newCardIndex = 0;
660      else if (newCardIndex >= this.cardCount)
661        newCardIndex = this.cardCount - 1;
662      this.selectCard(newCardIndex, /* animate */ true);
663    },
664
665    /**
666     * Cancel any current touch/slide as if we saw a touch end
667     */
668    cancelTouch: function() {
669      // Stop listening to any current touch
670      this.touchHandler_.cancelTouch();
671
672      // Ensure we're at a card bounary
673      this.transformToCurrentCard_(true);
674    },
675  };
676
677  return {
678    CardSlider: CardSlider
679  };
680});
681