/**
* Gallery
* Minimal content switcher class, with options for autoplay, navigation, pagination, and more.
*
* @author Marlon Marcello <marlon@wethecollective.com>
* @version 0.2.0
* @requirements wtc-utility-helpers, wtc-utility-preloader, wtc-controller-element
* @created Nov 30, 2016
*/
import Preloader from "wtc-utility-preloader";
import {
default as ElementController,
ExecuteControllers
} from "wtc-controller-element";
class Gallery extends ElementController {
/**
* The Gallery Class constructor
* @param {HTMLElement} element - the container element which the gallery will live in
* @param {Object} options - optional gallery behavior
* @param {(boolean|string)} options.nav - adds next and previous navigation buttons
* @param {(boolean|string)} options.autoplay - auto-advances the gallery
* @param {number} options.delay - duration (in miliseconds) between gallery transitions
* @param {(boolean|string)} options.pauseOnHover - pauses autoplay behvior when mouse/touch enters the gallery area
* @param {(boolean|string)} options.loop - enables left or right navigation, when the user reaches the first or last gallery item, respectively.
* @param {(boolean|string)} options.draggable - adds basic click-and-drag/swipe functionality to transition between gallery items
* @param {number} options.dragThreshold - minimum distance (in pixels) for a "drag" action to occur
* @param {(boolean|string)} options.pagination - creates a barebones navigation list of gallery items
* @param {?HTMLElement} options.paginationTarget - creates a navigation list of gallery items based on the element specified.
* @param {string} options.nextBtnMarkup - markup to override the default "next button" content
* @param {string} options.prevBtnMarkup - markup to override the default "previous button" content
* @param {string} options.liveRegionText - markup to override the default aria-live region content, use false to disable this
* @param {function} options.onLoad - function to run once the gallery is loaded
* @param {function} options.onWillChange - function to run before a gallery transition occurs
* @param {function} options.onHasChanged - function to run after a gallery transition occurs
*/
constructor(element, options = {}) {
super(element);
this.options = {
nav: this.element.getAttribute("data-nav") == "true" ? true : false,
debug: this.element.getAttribute("data-debug") == "true" ? true : false,
autoplay:
this.element.getAttribute("data-autoplay") == "true" ? true : false,
delay:
parseInt(this.element.getAttribute("data-delay")) > 0
? parseInt(this.element.getAttribute("data-delay"))
: 5000,
pauseOnHover:
this.element.getAttribute("data-pause-on-hover") == "true"
? true
: false,
loop: this.element.getAttribute("data-loop") == "true" ? true : false,
draggable:
this.element.getAttribute("data-draggable") == "true" ? true : false,
dragThreshold:
parseInt(this.element.getAttribute("data-drag-threshold")) > 0
? parseInt(this.element.getAttribute("data-drag-threshold"))
: 40,
pagination:
this.element.getAttribute("data-pagination") == "true" ? true : false,
paginationTarget:
this.element.getAttribute("data-pagination-target") &&
this.element.getAttribute("data-pagination-target").length > 1
? this.element.getAttribute("data-pagination-target")
: null,
nextBtnMarkup: this.element.getAttribute("data-next-btn-markup")
? this.element.getAttribute("data-next-btn-markup")
: 'Next <span class="visually-hidden">carousel item.</span>',
prevBtnMarkup: this.element.getAttribute("data-prev-btn-markup")
? this.element.getAttribute("data-prev-btn-markup")
: 'Previous <span class="visually-hidden">carousel item.</span>',
liveRegionText: this.element.getAttribute("data-live-region-text")
? this.element.getAttribute("data-live-region-text")
: "Active carousel item",
onLoad: null,
onWillChange: null,
onHasChanged: null
};
if (options) this.options = Object.assign({}, this.options, options);
this.wrapper = this.element.querySelector("ul");
this.items = [...this.wrapper.children];
this.overlay = document.createElement("div");
this.currentItem = this.items[0];
this.currentIndex = 0;
// Bind event listeners
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
// If nav is set to true, create buttons
if (this.options.nav) {
this.nextBtn = document.createElement("button");
this.nextBtn.innerHTML = this.options.nextBtnMarkup;
this.prevBtn = document.createElement("button");
this.prevBtn.innerHTML = this.options.prevBtnMarkup;
this.nextBtn.className = "gallery__nav gallery__nav-next";
this.prevBtn.className = "gallery__nav gallery__nav-prev";
this.nextBtn.addEventListener("click", this.next.bind(this));
this.prevBtn.addEventListener("click", this.prev.bind(this));
this.wrapper.insertAdjacentElement("afterend", this.nextBtn);
this.wrapper.insertAdjacentElement("afterend", this.prevBtn);
}
// If pagination is set to true, set up the item list
if (this.options.pagination) {
let itemList;
// if a nodeList was provided, use it.
// otherwise, build a generic list of buttons
if (this.options.paginationTarget) {
itemList = document.querySelector(this.options.paginationTarget);
let items = [...itemList.children];
items.forEach((el, i) => {
el.classList.add("gallery__pagination-item");
if (!el.dataset.index) el.dataset.index = i;
if (i === 0) el.classList.add("is-active");
el.addEventListener("click", this.handlePagination.bind(this));
});
} else {
let i = 0,
length = this.items.length;
itemList = document.createElement("ul");
for (i; i < length; i++) {
let item = document.createElement("li"),
itemBtn = document.createElement("button"),
itemBtnContent = document.createTextNode(i + 1);
item.classList.add("gallery__pagination-item");
item.dataset.index = i;
if (i === 0) item.classList.add("is-active");
item.addEventListener("click", this.handlePagination.bind(this));
itemBtn.appendChild(itemBtnContent);
item.appendChild(itemBtn);
itemList.appendChild(item);
}
this.element.appendChild(itemList);
}
this.paginationList = itemList;
this.paginationItems = [...this.paginationList.children];
itemList.classList.add("gallery__pagination");
}
// create live region for screen-reader to announce slide changes
this.liveRegion = null;
if (this.options.liveRegionText) {
this.liveRegion = document.createElement("p");
this.liveRegion.setAttribute("aria-live", "polite");
this.liveRegion.classList.add("visually-hidden");
this.element.insertAdjacentElement("afterbegin", this.liveRegion);
}
// Add pause-on-hover pointer events. Including a fallback to mouse events.
if (this.options.pauseOnHover) {
if (window.PointerEvent) {
element.addEventListener("pointerover", this.pause.bind(this), false);
element.addEventListener("pointerout", this.resume.bind(this), false);
} else {
element.addEventListener("mouseenter", this.pause.bind(this), false);
element.addEventListener("mouseleave", this.resume.bind(this), false);
}
}
// Add "draggable" events
if (this.options.draggable) {
this.dragStartX = null;
element.addEventListener(
"mousedown",
this.draggablePointerDown.bind(this),
false
);
element.addEventListener(
"touchstart",
this.draggablePointerDown.bind(this),
false
);
element.addEventListener(
"mouseup",
this.draggablePointerUp.bind(this),
false
);
element.addEventListener(
"touchend",
this.draggablePointerUp.bind(this),
false
);
}
// add base classes
this.element.classList.add("gallery");
this.overlay.classList.add("gallery__overlay");
this.wrapper.classList.add("gallery__wrapper");
this.items.forEach((item, i) => {
item.classList.add("gallery__item");
item.dataset.index = i;
item.setAttribute("tabindex", -1);
if (this.currentIndex !== i) {
// "hide" any focusable children on inactive elements
let focusableChildren = item.querySelectorAll(
"button, [href], [tabindex]"
);
for (let focusableEl of focusableChildren) {
focusableEl.setAttribute("tabindex", -1);
}
item.setAttribute("aria-hidden", "true");
}
item.addEventListener(
"transitionend",
this.itemTransitioned.bind(this, item)
);
});
// add state classes
this.currentItem.classList.add("is-active");
this.element.classList.add("is-loading");
// append main element
this.element.appendChild(this.overlay);
// preload images if any
let images = this.wrapper.querySelectorAll("img");
if (images.length > 0) {
let preloader = new Preloader({ debug: this.options.debug });
for (let item of images) {
preloader.add(item.getAttribute("src"), "image");
}
preloader.load(this.loaded.bind(this));
} else {
this.loaded();
}
}
/**
* Advances gallery to the index of the selected pagination item.
* @param {Object} e - the event object
*/
handlePagination(e) {
let target = e.target.closest(".gallery__pagination-item");
if (target) {
let paginationIndex = +target.dataset.index;
this.paginationItems.forEach((item, i) => {
if (paginationIndex === i) item.classList.add("is-active");
else item.classList.remove("is-active");
});
this.moveByIndex(paginationIndex);
// shift focus to active item. note this should only happen on pagination click,
// not on next/prev click https://www.w3.org/WAI/tutorials/carousels/functionality/#announce-the-current-item
this.currentItem.focus();
}
}
/**
* Stores the x-position of mouse/touch input
* @param {Object} e - the event object
*/
draggablePointerDown(e) {
if (e.target.closest("button")) {
return;
} else {
e.preventDefault();
let xPos = e.clientX || e.touches["0"].clientX;
this.dragStartX = xPos;
}
}
/**
* Advance gallery if drag distance meets or exceeds the established threshold.
* @param {Object} e - the event object
*/
draggablePointerUp(e) {
if (e.target.closest("button") || e.target.closest("[href]")) {
return;
} else {
e.preventDefault();
let xPos = e.clientX || e.changedTouches["0"].clientX;
if (Math.abs(xPos - this.dragStartX) > this.options.dragThreshold) {
if (xPos > this.dragStartX) {
this.prev();
} else {
this.next();
}
}
}
}
/**
* Adjust main wrapper height.
* @return {class} This
*/
resize() {
let newH = 0;
for (let item of this.items) {
let h = item.offsetHeight;
if (h > newH) {
newH = h;
}
}
this.wrapper.style.height = `${newH}px`;
return this;
}
/**
* Handles the setting of the timer for autoplay galleries,
* taking into consideration the document's visibility state.
*/
handleAutoplay() {
if (!this.options.autoplay) return;
if (document.visibilityState === "hidden") {
this.pause();
document.addEventListener(
"visibilitychange",
this.handleVisibilityChange
);
} else {
this.player = setTimeout(this.next.bind(this), this.options.delay);
}
}
/**
* Fires when the document's visbility chnages from the "hidden" state;
* Resumes autoplay functionality if applicable.
*/
handleVisibilityChange() {
if (!this.options.autoplay || document.visibilityState === "hidden") return;
document.removeEventListener(
"visibilitychange",
this.handleVisibilityChange
);
this.handleAutoplay();
}
/**
* Removes loading classes and starts autoplay.
* @return {class} This
*/
loaded() {
window.addEventListener("resize", this.resize.bind(this));
this.resize();
this.element.classList.remove("is-loading");
this.element.classList.add("is-loaded");
if (this.options.autoplay) this.handleAutoplay();
if (this.options.nav && !this.options.loop && this.currentIndex == 0) {
this.prevBtn.setAttribute("disabled", true);
}
if (typeof this.options.onLoad == "function") {
this.options.onLoad(this);
}
return this;
}
/**
* Helper method to remove CSS transition classes
* @param {DOMNode} item - Gallery item.
* @return {class} This.
*/
itemTransitioned(item) {
item.classList.remove("is-transitioning");
item.classList.remove("is-transitioning--center");
item.classList.remove("is-transitioning--backward");
item.classList.remove("is-transitioning--forward");
return this;
}
/**
* Changes active item based on its index, starts at 0
* @param {number} index
* @return {class} This
*/
moveByIndex(index) {
let next = this.items[index];
if (this.options.autoplay) {
clearTimeout(this.player);
}
if (!next) {
console.warn("No item with index: " + index);
return;
}
if (this.currentItem != next) {
this.currentItem.setAttribute("aria-hidden", "true");
next.removeAttribute("aria-hidden");
next.classList.add("is-active");
next.classList.add("is-transitioning");
next.classList.add("is-transitioning--center");
this.currentItem.classList.remove("is-active");
}
if (this.options.pagination) {
for (let item of this.paginationItems) {
if (item.dataset.index == index) {
item.classList.add("is-active");
} else {
item.classList.remove("is-active");
}
}
}
if (this.liveRegion && this.options.liveRegionText) {
this.liveRegion.innerHTML = `${this.options.liveRegionText}: ${index +
1} of ${this.items.length}.`;
}
const prev = this.currentItem;
this.currentItem = next;
this.currentIndex = index;
if (!this.options.loop && this.options.nav) {
if (this.currentIndex == this.items.length - 1) {
this.nextBtn.setAttribute("disabled", true);
this.prevBtn.removeAttribute("disabled");
} else if (this.currentIndex == 0) {
this.prevBtn.setAttribute("disabled", true);
this.nextBtn.removeAttribute("disabled");
} else {
this.nextBtn.removeAttribute("disabled");
this.prevBtn.removeAttribute("disabled");
}
}
if (typeof this.options.onHasChanged == "function") {
this.options.onHasChanged(this.currentItem, prev, this);
}
if (this.options.autoplay) this.handleAutoplay();
return this;
}
/**
* Changes active item
* @param {boolean} direction - True = forwards. False = backwards
* @return {class} This
*/
move(direction = true) {
if (this.options.autoplay) {
clearTimeout(this.player);
}
let next = direction
? this.currentItem.nextElementSibling
: this.currentItem.previousElementSibling;
if (!next) {
next = direction ? this.items[0] : this.items[this.items.length - 1];
}
next.classList.add("is-active");
next.classList.add("is-transitioning");
next.classList.add("is-transitioning--center");
this.currentItem.classList.remove("is-active");
this.currentItem.setAttribute("aria-hidden", "true");
next.removeAttribute("aria-hidden");
if (this.options.pagination) {
this.paginationItems.forEach((item, i) => {
if (i == next.dataset.index) item.classList.add("is-active");
else item.classList.remove("is-active");
});
}
const prev = this.currentItem;
this.currentItem = next;
this.currentIndex = +next.dataset.index;
if (!this.options.loop && this.options.nav) {
if (this.currentIndex == this.items.length - 1) {
this.nextBtn.setAttribute("disabled", true);
this.prevBtn.removeAttribute("disabled");
} else if (this.currentIndex == 0) {
this.prevBtn.setAttribute("disabled", true);
this.nextBtn.removeAttribute("disabled");
} else {
this.nextBtn.removeAttribute("disabled");
this.prevBtn.removeAttribute("disabled");
}
}
if (typeof this.options.onHasChanged == "function") {
this.options.onHasChanged(this.currentItem, prev, this);
}
if (this.options.autoplay) {
this.handleAutoplay();
} else if (this.liveRegion && this.options.liveRegionText) {
this.liveRegion.innerHTML = `${this.options.liveRegionText}: ${this
.currentIndex + 1} of ${this.items.length}.`;
}
}
/**
* Move forward
* @return {class} This.
*/
next() {
this.currentIndex = parseInt(this.currentItem.dataset.index);
if (typeof this.options.onWillChange == "function") {
this.options.onWillChange(this, true);
}
this.currentItem.classList.remove("is-transitioning--center");
this.currentItem.classList.add("is-transitioning");
this.currentItem.classList.add("is-transitioning--backward");
this.move();
return this;
}
/**
* Move backwards
* @return {class} This.
*/
prev() {
this.currentIndex = this.currentItem.dataset.index;
if (typeof this.options.onWillChange == "function") {
this.options.onWillChange(this, false);
}
this.currentItem.classList.remove("is-transitioning--center");
this.currentItem.classList.add("is-transitioning");
this.currentItem.classList.add("is-transitioning--forward");
this.move(false);
return this;
}
/**
* Get currently-active gallery item
* @return {DOMNode} Element.
*/
get active() {
return this.currentItem;
}
/**
* Get the index of the currently-active gallery item
* @return {DOMNode} Element.
*/
get activeIndex() {
return this.currentIndex;
}
/**
* Pause autoplaying gallery
* @return {class} This.
*/
pause() {
if (this.options.autoplay) {
clearTimeout(this.player);
}
return this;
}
/**
* Resume autoplaying gallery
* @return {class} This.
*/
resume() {
if (this.options.autoplay) this.handleAutoplay();
return this;
}
}
ExecuteControllers.registerController(Gallery, "Gallery");
export default Gallery;