Update To 11.40.268.0
[platform/framework/web/crosswalk.git] / src / ui / webui / resources / js / cr / ui / card_slider.js
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
26 cr.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 {!Event} e The TouchHandler event.
601      * @private
602      */
603     onTouchStart_: function(e) {
604       e = /** @type {!cr.ui.TouchHandler.Event} */(e);
605       this.container_.style.WebkitTransition = '';
606       e.enableDrag = true;
607     },
608
609     /**
610      * Tell the TouchHandler that dragging is acceptable when the user begins by
611      * scrolling horizontally and there is more than one card to slide.
612      * @param {!Event} e The TouchHandler event.
613      * @private
614      */
615     onDragStart_: function(e) {
616       e = /** @type {!cr.ui.TouchHandler.Event} */(e);
617       e.enableDrag = this.cardCount > 1 && Math.abs(e.dragDeltaX) >
618           Math.abs(e.dragDeltaY);
619     },
620
621     /**
622      * On each drag move event reposition the container appropriately so the
623      * cards look like they are sliding.
624      * @param {!Event} e The TouchHandler event.
625      * @private
626      */
627     onDragMove_: function(e) {
628       e = /** @type {!cr.ui.TouchHandler.Event} */(e);
629       var deltaX = e.dragDeltaX;
630       // If dragging beyond the first or last card then apply a backoff so the
631       // dragging feels stickier than usual.
632       if (!this.currentCard && deltaX > 0 ||
633           this.currentCard == (this.cards_.length - 1) && deltaX < 0) {
634         deltaX /= 2;
635       }
636       this.translateTo_(this.currentLeft_ + deltaX);
637     },
638
639     /**
640      * On drag end events we may want to transition to another card, depending
641      * on the ending position of the drag and the velocity of the drag.
642      * @param {!Event} e The TouchHandler event.
643      * @private
644      */
645     onDragEnd_: function(e) {
646       e = /** @type {!cr.ui.TouchHandler.Event} */(e);
647       var deltaX = e.dragDeltaX;
648       var velocity = this.touchHandler_.getEndVelocity().x;
649       var newX = this.currentLeft_ + deltaX;
650       var newCardIndex = Math.round(-newX / this.cardWidth_);
651
652       if (newCardIndex == this.currentCard && Math.abs(velocity) >
653           CardSlider.TRANSITION_VELOCITY_THRESHOLD_) {
654         // The drag wasn't far enough to change cards but the velocity was
655         // high enough to transition anyways. If the velocity is to the left
656         // (negative) then the user wishes to go right (card + 1).
657         newCardIndex += velocity > 0 ? -1 : 1;
658       }
659       // Ensure that the new card index is valid.  The new card index could be
660       // invalid if a swipe suggests scrolling off the end of the list of
661       // cards.
662       if (newCardIndex < 0)
663         newCardIndex = 0;
664       else if (newCardIndex >= this.cardCount)
665         newCardIndex = this.cardCount - 1;
666       this.selectCard(newCardIndex, /* animate */ true);
667     },
668
669     /**
670      * Cancel any current touch/slide as if we saw a touch end
671      */
672     cancelTouch: function() {
673       // Stop listening to any current touch
674       this.touchHandler_.cancelTouch();
675
676       // Ensure we're at a card bounary
677       this.transformToCurrentCard_(true);
678     },
679   };
680
681   return {
682     CardSlider: CardSlider
683   };
684 });