blob: 8fe59e1c616beb70af17da98fb51b10f82bf54d0 [file] [log] [blame]
Copybara botbe50d492023-11-30 00:16:42 +01001/**
2 * @license
3 * Copyright 2016 Leif Olsen. All Rights Reserved.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 * http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 *
17 * This code is built with Google Material Design Lite,
18 * which is Licensed under the Apache License, Version 2.0
19 */
20
21/**
22 * Image carousel
23 */
24
25import intervalFunction from '../utils/interval-function';
26import { inOutQuintic } from '../utils/easing';
27import { jsonStringToObject} from '../utils/json-utils';
28import {
29 VK_TAB,
30 VK_ENTER,
31 VK_ESC,
32 VK_SPACE,
33 VK_PAGE_UP,
34 VK_PAGE_DOWN,
35 VK_END,
36 VK_HOME,
37 VK_ARROW_LEFT,
38 VK_ARROW_UP,
39 VK_ARROW_RIGHT,
40 VK_ARROW_DOWN,
41 IS_UPGRADED,
42 IS_FOCUSED,
43 MDL_RIPPLE,
44 MDL_RIPPLE_COMPONENT,
45 MDL_RIPPLE_EFFECT,
46 MDL_RIPPLE_EFFECT_IGNORE_EVENTS
47} from '../utils/constants';
48
49const MDL_RIPPLE_CONTAINER = 'mdlext-carousel__slide__ripple-container';
50
51
52(function() {
53 'use strict';
54
55 //const CAROUSEL = 'mdlext-carousel';
56 const SLIDE = 'mdlext-carousel__slide';
57 const ROLE = 'list';
58 const SLIDE_ROLE = 'listitem';
59
60
61 /**
62 * @constructor
63 * @param {Element} element The element that will be upgraded.
64 */
65 const MaterialExtCarousel = function MaterialExtCarousel(element) {
66 // Stores the element.
67 this.element_ = element;
68
69 // Default config
70 this.config_ = {
71 interactive : true,
72 autostart : false,
73 type : 'slide',
74 interval : 1000,
75 animationLoop: intervalFunction(1000)
76 };
77
78 this.scrollAnimation_ = intervalFunction(33);
79
80 // Initialize instance.
81 this.init();
82 };
83
84 window['MaterialExtCarousel'] = MaterialExtCarousel;
85
86
87 /**
88 * Start slideshow animation
89 * @private
90 */
91 MaterialExtCarousel.prototype.startSlideShow_ = function() {
92
93 const nextSlide = () => {
94 let slide = this.element_.querySelector(`.${SLIDE}[aria-selected]`);
95 if(slide) {
96 slide.removeAttribute('aria-selected');
97 slide = slide.nextElementSibling;
98 }
99 if(!slide) {
100 slide = this.element_.querySelector(`.${SLIDE}:first-child`);
101 this.animateScroll_(0);
102 }
103 if(slide) {
104 this.moveSlideIntoViewport_(slide);
105 slide.setAttribute('aria-selected', '');
106 this.emitSelectEvent_('next', null, slide);
107 return true;
108 }
109 return false;
110 };
111
112 const nextScroll = direction => {
113 let nextDirection = direction;
114
115 if('next' === direction && this.element_.scrollLeft === this.element_.scrollWidth - this.element_.clientWidth) {
116 nextDirection = 'prev';
117 }
118 else if(this.element_.scrollLeft === 0) {
119 nextDirection = 'next';
120 }
121 const x = 'next' === nextDirection
122 ? Math.min(this.element_.scrollLeft + this.element_.clientWidth, this.element_.scrollWidth - this.element_.clientWidth)
123 : Math.max(this.element_.scrollLeft - this.element_.clientWidth, 0);
124
125 this.animateScroll_(x, 1000);
126 return nextDirection;
127 };
128
129
130 if(!this.config_.animationLoop.started) {
131 this.config_.animationLoop.interval = this.config_.interval;
132 let direction = 'next';
133
134 if('scroll' === this.config_.type) {
135 this.config_.animationLoop.start( () => {
136 direction = nextScroll(direction);
137 return true; // It runs until cancelSlideShow_ is triggered
138 });
139 }
140 else {
141 nextSlide();
142 this.config_.animationLoop.start( () => {
143 return nextSlide(); // It runs until cancelSlideShow_ is triggered
144 });
145 }
146 }
147
148 // TODO: Pause animation when carousel is not in browser viewport or user changes tab
149 };
150
151 /**
152 * Cancel slideshow if running. Emmits a 'pause' event
153 * @private
154 */
155 MaterialExtCarousel.prototype.cancelSlideShow_ = function() {
156 if(this.config_.animationLoop.started) {
157 this.config_.animationLoop.stop();
158 this.emitSelectEvent_('pause', VK_ESC, this.element_.querySelector(`.${SLIDE}[aria-selected]`));
159 }
160 };
161
162 /**
163 * Animate scroll
164 * @param newPosition
165 * @param newDuration
166 * @param completedCallback
167 * @private
168 */
169 MaterialExtCarousel.prototype.animateScroll_ = function( newPosition, newDuration, completedCallback ) {
170
171 const start = this.element_.scrollLeft;
172 const distance = newPosition - start;
173
174 if(distance !== 0) {
175 const duration = Math.max(Math.min(Math.abs(distance), newDuration||400), 100); // duration is between 100 and newDuration||400ms||distance
176 let t = 0;
177 this.scrollAnimation_.stop();
178 this.scrollAnimation_.start( timeElapsed => {
179 t += timeElapsed;
180 if(t < duration) {
181 this.element_.scrollLeft = inOutQuintic(t, start, distance, duration);
182 return true;
183 }
184 else {
185 this.element_.scrollLeft = newPosition;
186 if(completedCallback) {
187 completedCallback();
188 }
189 return false;
190 }
191 });
192 }
193 else {
194 if(completedCallback) {
195 completedCallback();
196 }
197 }
198 };
199
200 /**
201 * Execute commend
202 * @param event
203 * @private
204 */
205 MaterialExtCarousel.prototype.command_ = function( event ) {
206 let x = 0;
207 let slide = null;
208 const a = event.detail.action.toLowerCase();
209
210 // Cancel slideshow if running
211 this.cancelSlideShow_();
212
213 switch (a) {
214 case 'first':
215 slide = this.element_.querySelector(`.${SLIDE}:first-child`);
216 break;
217
218 case 'last':
219 x = this.element_.scrollWidth - this.element_.clientWidth;
220 slide = this.element_.querySelector(`.${SLIDE}:last-child`);
221 break;
222
223 case 'scroll-prev':
224 x = Math.max(this.element_.scrollLeft - this.element_.clientWidth, 0);
225 break;
226
227 case 'scroll-next':
228 x = Math.min(this.element_.scrollLeft + this.element_.clientWidth, this.element_.scrollWidth - this.element_.clientWidth);
229 break;
230
231 case 'next':
232 case 'prev':
233 slide = this.element_.querySelector(`.${SLIDE}[aria-selected]`);
234 if(slide) {
235 slide = a === 'next' ? slide.nextElementSibling : slide.previousElementSibling;
236 this.setAriaSelected_(slide);
237 this.emitSelectEvent_(a, null, slide);
238 }
239 return;
240
241 case 'play':
242 Object.assign(this.config_, event.detail);
243 this.startSlideShow_();
244 return;
245
246 case 'pause':
247 return;
248
249 default:
250 return;
251 }
252
253 this.animateScroll_(x, undefined, () => {
254 if ('scroll-next' === a || 'scroll-prev' === a) {
255 const slides = this.getSlidesInViewport_();
256 if (slides.length > 0) {
257 slide = 'scroll-next' === a ? slides[0] : slides[slides.length - 1];
258 }
259 }
260 this.setAriaSelected_(slide);
261 this.emitSelectEvent_(a, null, slide);
262 });
263 };
264
265 /**
266 * Handles custom command event, 'scroll-prev', 'scroll-next', 'first', 'last', next, prev, play, pause
267 * @param event. A custom event
268 * @private
269 */
270 MaterialExtCarousel.prototype.commandHandler_ = function( event ) {
271 event.preventDefault();
272 event.stopPropagation();
273 if(event.detail && event.detail.action) {
274 this.command_(event);
275 }
276 };
277
278 /**
279 * Handle keypress
280 * @param event
281 * @private
282 */
283 MaterialExtCarousel.prototype.keyDownHandler_ = function(event) {
284
285 if (event && event.target && event.target !== this.element_) {
286
287 let action = 'first';
288
289 if ( event.keyCode === VK_HOME || event.keyCode === VK_END
290 || event.keyCode === VK_PAGE_UP || event.keyCode === VK_PAGE_DOWN) {
291
292 event.preventDefault();
293 if (event.keyCode === VK_END) {
294 action = 'last';
295 }
296 else if (event.keyCode === VK_PAGE_UP) {
297 action = 'scroll-prev';
298 }
299 else if (event.keyCode === VK_PAGE_DOWN) {
300 action = 'scroll-next';
301 }
302
303 const cmd = new CustomEvent('select', {
304 detail: {
305 action: action,
306 }
307 });
308 this.command_(cmd);
309 }
310 else if ( event.keyCode === VK_TAB
311 || event.keyCode === VK_ENTER || event.keyCode === VK_SPACE
312 || event.keyCode === VK_ARROW_UP || event.keyCode === VK_ARROW_LEFT
313 || event.keyCode === VK_ARROW_DOWN || event.keyCode === VK_ARROW_RIGHT) {
314
315 let slide = getSlide_(event.target);
316
317 if(!slide) {
318 return;
319 }
320
321 // Cancel slideshow if running
322 this.cancelSlideShow_();
323
324 switch (event.keyCode) {
325 case VK_ARROW_UP:
326 case VK_ARROW_LEFT:
327 action = 'prev';
328 slide = slide.previousElementSibling;
329 break;
330
331 case VK_ARROW_DOWN:
332 case VK_ARROW_RIGHT:
333 action = 'next';
334 slide = slide.nextElementSibling;
335 break;
336
337 case VK_TAB:
338 if (event.shiftKey) {
339 action = 'prev';
340 slide = slide.previousElementSibling;
341 }
342 else {
343 action = 'next';
344 slide = slide.nextElementSibling;
345 }
346 break;
347
348 case VK_SPACE:
349 case VK_ENTER:
350 action = 'select';
351 break;
352 }
353
354 if(slide) {
355 event.preventDefault();
356 setFocus_(slide);
357 this.emitSelectEvent_(action, event.keyCode, slide);
358 }
359 }
360 }
361 };
362
363 /**
364 * Handle dragging
365 * @param event
366 * @private
367 */
368 MaterialExtCarousel.prototype.dragHandler_ = function(event) {
369 event.preventDefault();
370
371 // Cancel slideshow if running
372 this.cancelSlideShow_();
373
374 let updating = false;
375 let rAFDragId = 0;
376
377 const startX = event.clientX || (event.touches !== undefined ? event.touches[0].clientX : 0);
378 let prevX = startX;
379 const targetElement = event.target;
380
381 const update = e => {
382 const currentX = (e.clientX || (e.touches !== undefined ? e.touches[0].clientX : 0));
383 const dx = prevX - currentX;
384
385 if(dx < 0) {
386 this.element_.scrollLeft = Math.max(this.element_.scrollLeft + dx, 0);
387 }
388 else if(dx > 0) {
389 this.element_.scrollLeft = Math.min(this.element_.scrollLeft + dx, this.element_.scrollWidth - this.element_.clientWidth);
390 }
391
392 prevX = currentX;
393 updating = false;
394 };
395
396 // drag handler
397 const drag = e => {
398 e.preventDefault();
399
400 if(!updating) {
401 rAFDragId = window.requestAnimationFrame( () => update(e));
402 updating = true;
403 }
404 };
405
406 // end drag handler
407 const endDrag = e => {
408 e.preventDefault();
409
410 this.element_.removeEventListener('mousemove', drag);
411 this.element_.removeEventListener('touchmove', drag);
412 window.removeEventListener('mouseup', endDrag);
413 window.removeEventListener('touchend', endDrag);
414
415 // cancel any existing drag rAF, see: http://www.html5rocks.com/en/tutorials/speed/animations/
416 window.cancelAnimationFrame(rAFDragId);
417
418 const slide = getSlide_(targetElement);
419 setFocus_(slide);
420 this.emitSelectEvent_('click', null, slide);
421 };
422
423 this.element_.addEventListener('mousemove', drag);
424 this.element_.addEventListener('touchmove', drag);
425 window.addEventListener('mouseup', endDrag);
426 window.addEventListener('touchend',endDrag);
427 };
428
429 /**
430 * Handle click
431 * @param event
432 * @private
433 */
434 MaterialExtCarousel.prototype.clickHandler_ = function(event) {
435 // Click is handled by drag
436 event.preventDefault();
437 };
438
439 /**
440 * Handle focus
441 * @param event
442 * @private
443 */
444 MaterialExtCarousel.prototype.focusHandler_ = function(event) {
445 const slide = getSlide_(event.target);
446 if(slide) {
447 // The last focused/selected slide has 'aria-selected', even if focus is lost
448 this.setAriaSelected_(slide);
449 slide.classList.add(IS_FOCUSED);
450 }
451 };
452
453 /**
454 * Handle blur
455 * @param event
456 * @private
457 */
458 MaterialExtCarousel.prototype.blurHandler_ = function(event) {
459 const slide = getSlide_(event.target);
460 if(slide) {
461 slide.classList.remove(IS_FOCUSED);
462 }
463 };
464
465 /**
466 * Emits a custeom 'select' event
467 * @param command
468 * @param keyCode
469 * @param slide
470 * @private
471 */
472 MaterialExtCarousel.prototype.emitSelectEvent_ = function(command, keyCode, slide) {
473
474 if(slide) {
475 this.moveSlideIntoViewport_(slide);
476
477 const evt = new CustomEvent('select', {
478 bubbles: true,
479 cancelable: true,
480 detail: {
481 command: command,
482 keyCode: keyCode,
483 source: slide
484 }
485 });
486 this.element_.dispatchEvent(evt);
487 }
488 };
489
490 /**
491 * Get the first visible slide in component viewport
492 * @private
493 */
494 MaterialExtCarousel.prototype.getSlidesInViewport_ = function() {
495 const carouselRect = this.element_.getBoundingClientRect();
496
497 const slidesInViewport = [...this.element_.querySelectorAll(`.${SLIDE}`)].filter( slide => {
498 const slideRect = slide.getBoundingClientRect();
499 return slideRect.left >= carouselRect.left && slideRect.right <= carouselRect.right;
500 });
501 return slidesInViewport;
502 };
503
504 /**
505 * Move slide into component viewport - if needed
506 * @param slide
507 * @private
508 */
509 MaterialExtCarousel.prototype.moveSlideIntoViewport_ = function(slide) {
510 const carouselRect = this.element_.getBoundingClientRect();
511 const slideRect = slide.getBoundingClientRect();
512
513 if(slideRect.left < carouselRect.left) {
514 const x = this.element_.scrollLeft - (carouselRect.left - slideRect.left);
515 this.animateScroll_(x);
516 }
517 else if(slideRect.right > carouselRect.right) {
518 const x = this.element_.scrollLeft - (carouselRect.right - slideRect.right);
519 this.animateScroll_(x);
520 }
521 };
522
523
524 /**
525 * Removes 'aria-selected' from all slides in carousel
526 * @private
527 */
528 MaterialExtCarousel.prototype.setAriaSelected_ = function(slide) {
529 if(slide) {
530 [...this.element_.querySelectorAll(`.${SLIDE}[aria-selected]`)].forEach(
531 slide => slide.removeAttribute('aria-selected')
532 );
533 slide.setAttribute('aria-selected', '');
534 }
535 };
536
537 /**
538 * Removes event listeners
539 * @private
540 */
541 MaterialExtCarousel.prototype.removeListeners_ = function() {
542 this.element_.removeEventListener('focus', this.focusHandler_);
543 this.element_.removeEventListener('blur', this.blurHandler_);
544 this.element_.removeEventListener('keydown', this.keyDownHandler_);
545 this.element_.removeEventListener('mousedown', this.dragHandler_);
546 this.element_.removeEventListener('touchstart', this.dragHandler_);
547 this.element_.removeEventListener('click', this.clickHandler_, false);
548 this.element_.removeEventListener('command', this.commandHandler_);
549 this.element_.removeEventListener('mdl-componentdowngraded', this.mdlDowngrade_);
550 };
551
552
553 // Helpers
554 const getSlide_ = element => {
555 return element.closest(`.${SLIDE}`);
556 };
557
558 const setFocus_ = slide => {
559 if(slide) {
560 slide.focus();
561 }
562 };
563
564 const addRipple_ = slide => {
565 if(!slide.querySelector(`.${MDL_RIPPLE_CONTAINER}`)) {
566 const rippleContainer = document.createElement('span');
567 rippleContainer.classList.add(MDL_RIPPLE_CONTAINER);
568 rippleContainer.classList.add(MDL_RIPPLE_EFFECT);
569 const ripple = document.createElement('span');
570 ripple.classList.add(MDL_RIPPLE);
571 rippleContainer.appendChild(ripple);
572
573 const img = slide.querySelector('img');
574 if (img) {
575 // rippleContainer blocks image title
576 rippleContainer.title = img.title;
577 }
578 slide.appendChild(rippleContainer);
579 componentHandler.upgradeElement(rippleContainer, MDL_RIPPLE_COMPONENT);
580 }
581 };
582 // End helpers
583
584
585 // Public methods.
586
587 /**
588 * Cancel animation - if running.
589 *
590 * @public
591 */
592 MaterialExtCarousel.prototype.stopAnimation = function() {
593 this.config_.animationLoop.stop();
594 };
595 MaterialExtCarousel.prototype['stopAnimation'] = MaterialExtCarousel.prototype.stopAnimation;
596
597
598 /**
599 * Upgrade slides
600 * Use if more list elements are added later (dynamically)
601 *
602 * @public
603 */
604 MaterialExtCarousel.prototype.upgradeSlides = function() {
605
606 const hasRippleEffect = this.element_.classList.contains(MDL_RIPPLE_EFFECT);
607
608 [...this.element_.querySelectorAll(`.${SLIDE}`)].forEach( slide => {
609
610 slide.setAttribute('role', SLIDE_ROLE);
611
612 if(this.config_.interactive) {
613 if(!slide.getAttribute('tabindex')) {
614 slide.setAttribute('tabindex', '0');
615 }
616 if (hasRippleEffect) {
617 addRipple_(slide);
618 }
619 }
620 else {
621 slide.setAttribute('tabindex', '-1');
622 }
623 });
624 };
625 MaterialExtCarousel.prototype['upgradeSlides'] = MaterialExtCarousel.prototype.upgradeSlides;
626
627
628 /**
629 * Get config object
630 *
631 * @public
632 */
633 MaterialExtCarousel.prototype.getConfig = function() {
634 return this.config_;
635 };
636 MaterialExtCarousel.prototype['getConfig'] = MaterialExtCarousel.prototype.getConfig;
637
638 /**
639 * Initialize component
640 */
641 MaterialExtCarousel.prototype.init = function() {
642
643 if (this.element_) {
644 // Config
645 if(this.element_.hasAttribute('data-config')) {
646 this.config_ = jsonStringToObject(this.element_.getAttribute('data-config'), this.config_);
647 }
648
649 // Wai-Aria
650 this.element_.setAttribute('role', ROLE);
651
652 // Prefer tabindex -1
653 if(!Number.isInteger(this.element_.getAttribute('tabindex'))) {
654 this.element_.setAttribute('tabindex', -1);
655 }
656
657 // Remove listeners, just in case ...
658 this.removeListeners_();
659
660 if(this.config_.interactive) {
661
662 // Ripple
663 const hasRippleEffect = this.element_.classList.contains(MDL_RIPPLE_EFFECT);
664 if (hasRippleEffect) {
665 this.element_.classList.add(MDL_RIPPLE_EFFECT_IGNORE_EVENTS);
666 }
667
668 // Listen to focus/blur events
669 this.element_.addEventListener('focus', this.focusHandler_.bind(this), true);
670 this.element_.addEventListener('blur', this.blurHandler_.bind(this), true);
671
672 // Listen to keyboard events
673 this.element_.addEventListener('keydown', this.keyDownHandler_.bind(this), false);
674
675 // Listen to drag events
676 this.element_.addEventListener('mousedown', this.dragHandler_.bind(this), false);
677 this.element_.addEventListener('touchstart', this.dragHandler_.bind(this), false);
678
679 // Listen to click events
680 this.element_.addEventListener('click', this.clickHandler_.bind(this), false);
681 }
682
683 // Listen to custom 'command' event
684 this.element_.addEventListener('command', this.commandHandler_.bind(this), false);
685
686 // Listen to 'mdl-componentdowngraded' event
687 this.element_.addEventListener('mdl-componentdowngraded', this.mdlDowngrade_.bind(this));
688
689 // Slides collection
690 this.upgradeSlides();
691
692 // Set upgraded flag
693 this.element_.classList.add(IS_UPGRADED);
694
695 if(this.config_.autostart) {
696 // Start slideshow
697 this.startSlideShow_();
698 }
699 }
700 };
701
702 /*
703 * Downgrade component
704 * E.g remove listeners and clean up resources
705 */
706 MaterialExtCarousel.prototype.mdlDowngrade_ = function() {
707 'use strict';
708 //console.log('***** MaterialExtCarousel.mdlDowngrade_');
709
710 // Stop animation - if any
711 this.stopAnimation();
712
713 // Remove listeners
714 this.removeListeners_();
715 };
716
717 // The component registers itself. It can assume componentHandler is available
718 // in the global scope.
719 /* eslint no-undef: 0 */
720 componentHandler.register({
721 constructor: MaterialExtCarousel,
722 classAsString: 'MaterialExtCarousel',
723 cssClass: 'mdlext-js-carousel',
724 widget: true
725 });
726})();