@ulu/frontend
Version:
A versatile SCSS and JavaScript component library offering configurable, accessible components and flexible integration into any project, with SCSS modules suitable for modern JS frameworks.
578 lines (544 loc) • 19.9 kB
JavaScript
/**
* @module ui/slider
*/
// =============================================================================
// Slider
// =============================================================================
// Version: 1.0.11
// Changes:
// 1.0.11 | Updates for ComponentInitializer
// 1.0.10 | Fix bug when two and going in reverse
// 1.0.9 | Fix bug when there are only 2 slides (not sliding correctly [reverse because of switchSlide])
// this is addressed now and should slide infinity between two slides
// 1.0.8 | Change API, to elements object (from individual arguments),
// Add the ability to specify the element to append controls within
// 1.0.6 | Add transition class for changes during transition,
// add will-change to the transition
// 1.0.5 | Fix transition event difference on windows, convert all
// async stuff to promises and simplify
// 1.0.4 | Remove live region announcement (only used if auto rotate)
// Todo: - Create destroy method to cleanup listeners
// Reference: https://www.w3.org/WAI/tutorials/carousels/working-example/
// https://www.w3.org/TR/wai-aria-practices/examples/carousel/carousel-1.html#
// https://www.w3.org/TR/wai-aria-practices-1.1/examples/carousel/carousel-1.html
// https://www.accessibilityoz.com/
// https://www.sitepoint.com/unbearable-accessible-slideshow/
// https://dev.opera.com/articles/css-will-change-property/
// * Will Change use
import { ComponentInitializer } from "../utils/system.js";
import { wrapSettingString } from "../settings.js";
import maintain from 'ally.js/maintain/_maintain';
import { hasRequiredProps } from '@ulu/utils/object.js';
import { trimWhitespace } from "@ulu/utils/string.js";
import { debounce } from "@ulu/utils/performance.js";
import { log, logError, logWarning } from "../utils/class-logger.js";
import setupSwipeListener from "swipe-listener";
/**
* Slider Component Initializer
*/
export const initializer = new ComponentInitializer({
type: "slider",
baseAttribute: "data-ulu-slider"
});
const attrSelectorTrack = initializer.attributeSelector("track");
const attrSelectorTrackContainer = initializer.attributeSelector("track-container");
const attrSelectorControlContext = initializer.attributeSelector("control-context");
const attrSelectorSlide = initializer.attributeSelector("slide");
const instances = [];
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
const eventOnce = { once: true };
const cssDuration = d => `${ d }ms`;
// Resize handlers for all slider instances, Load event to avoid triggering
addEventListener('load', () => {
addEventListener('resize', debounce(() => {
Slider.instances.forEach(i => i.handleResize());
}, 250));
});
const requiredElements = [
"container",
"trackContainer",
"track",
"slides"
];
/**
* Initialize all sliders based on data attribute selectors
*/
export function init() {
initializer.init({
withData: true,
events: ["pageModified"],
setup({ element, data, initialize }) {
setupSlider(element, data);
initialize();
}
});
}
/**
* Setup single slider instance from querying via data attribute selectors
* @param {Node} container The slide container to query children from
* @param {Object} options Options for slider class
*/
export function setupSlider(container, options) {
const config = Object.assign({}, options);
const elements = {
container,
track: container.querySelector(attrSelectorTrack),
trackContainer: container.querySelector(attrSelectorTrackContainer),
controlContext: container.querySelector(attrSelectorControlContext),
slides: container.querySelectorAll(attrSelectorSlide)
};
// This was added because there was an issue on the new windows, need to test this
// config.transitionFade = true;
if (elements.slides.length) {
instances.push(new Slider(elements, config, false));
}
}
/**
* Class that controls slider
*/
export class Slider {
static instances = [];
/**
* Default options for slider
*/
static defaults = {
classAccessiblyHidden: "hidden-visually",
namespace: "Slider",
events: {},
transition: true,
transitionFade: false,
transitionDuration: 700,
transitionDurationExit: 400,
transitionTimingFunction: "ease-in-out",
buttonClasses: ["button", "button--icon"],
iconClassPrevious: wrapSettingString("iconClassPrevious"),
iconClassNext: wrapSettingString("iconClassNext"),
swipeEnabled: true,
swipeOptions: {
preventScroll: true
}
}
constructor(elements, config) {
const options = Object.assign({}, Slider.defaults, config);
this.options = options;
this.slide = null;
this.index = null;
this.swipeInstance = null;
this.swipeListener = null;
this.swipeImageListener = null;
this.transitioning = false;
if (!hasRequiredProps(requiredElements)) {
logError(this, 'Missing a required Element');
}
if (!elements.slides.length) {
logError(this, "Missing slides");
}
this.slides = [ ...elements.slides ].map((element, index) => {
return {
element,
index,
number: index + 1
}
});
this.elements = {
...elements,
...this.createControls(elements.controlContext || elements.container),
...this.createNav(elements.navContext || elements.container)
};
// Choose the appropriate transition method
this.transition = options.transition ? options.transitionFade || reduceMotion
? this.fadeTransition : this.slideTransition : this.noTransition;
this.setup();
this.goto(0, null, "init");
log(this, "Slider Instance Created", this);
Slider.instances.push(this);
}
/**
* Sliding mechanism needs translate updated on resize
*/
handleResize() {
const { slide, transition, slideTransition} = this;
if (transition === slideTransition && slide) {
this.translateTo(slide.element.offsetLeft, 0);
}
}
/**
* Goto to the previous slide
*/
previous(event) {
const { index: lastIndex, slides } = this;
const last = slides.length - 1;
const prev = lastIndex - 1;
const index = prev < 0 ? last : prev;
this.emit("previous", [event, index]);
this.goto(index, event, "previous");
}
/**
* Goto to the next slide
*/
next(event) {
const { index: lastIndex, slides } = this;
const next = lastIndex + 1;
const index = next > slides.length - 1 ? 0 : next;
this.emit("next", [event, index]);
this.goto(index, event, "next");
}
/**
* Makes sure that no matter what the callback is called if transition event
* doesn't start or fails to finish/cancel
* @param {number} element
* @param {number} duration Duration to wait for complete
* @param {Function} beginTransition Css changes to begin/start transtion
*/
ensureTransitionEnds(element, duration, beginTransition) {
return new Promise(resolve => {
const tid = {};
// If the transition has started remove the fallback for start
// and set one for the end
const onStart = () => {
clearTimeout(tid.start);
tid.end = setTimeout(onComplete, duration + 500);
};
// The transition has completed, cleanup and resolve
const onComplete = () => {
clearTimeout(tid.start);
clearTimeout(tid.end);
element.removeEventListener("transitionrun", onStart, eventOnce);
element.removeEventListener('transitionend', onComplete, eventOnce);
element.removeEventListener('transitioncancel', onComplete, eventOnce);
resolve();
};
// Wait for animation to start, also set a timer to ensure that
// if this event never fires for any reason, the promise will resolve
element.addEventListener("transitionrun", onStart, eventOnce);
// If it has started it will be waiting for the end
// If it never ends for any reason, the promise will resolve
element.addEventListener('transitionend', onComplete, eventOnce);
element.addEventListener('transitioncancel', onComplete, eventOnce);
tid.start = setTimeout(onComplete, duration + 500);
// Apply users css changes
element.style.transitionDuration = cssDuration(duration);
beginTransition();
// Bypass events if no duration or 0
if (!duration) {
onComplete();
}
});
}
/**
* Translate the track to X
*/
translateTo(x, duration) {
const { track } = this.elements;
const set = () => track.style.transform = `translateX(-${ x }px)`;
// tell brwoser we're about to animate
track.style.willChange = "transform";
return this.ensureTransitionEnds(track, duration, set).then(() => {
// Remove to avoid any issues with optimization
track.style.willChange = "auto";
});
}
/**
* Show's a specifc slide and hides others, except when passing true to show all
* then all slides will visible
*/
setVisibility(activeSlide, showAll) {
if (!showAll) {
activeSlide.element.style.visibility = "visible";
}
this.slides.forEach(slide => {
if (slide !== activeSlide) {
slide.element.style.visibility = showAll ? "visible" : "hidden";
}
});
}
/**
* Perform a fade on a single slide
*/
fadeSlide(slide, visible) {
const { options } = this;
const { element } = slide;
const duration = visible ? options.transitionDuration : options.transitionDurationExit;
return this.ensureTransitionEnds(element, duration, () => {
element.style.opacity = visible ? "1" : "0";
});
}
/**
* Handler for the entire slide transtion
*/
async slideTransition({ slide, index, old, oldIndex, triggerType }) {
const count = this.slides.length;
const reverse = triggerType === "previous";
const lastIndex = count - 1;
const lastToFirst = index === 0 && oldIndex === lastIndex;
const firstToLast = index === lastIndex && oldIndex === 0;
let switchSlide;
let duration = this.options.transitionDuration;
// Set duration based on how many slides to traverse
// First to last or the opposite are single slide animations
if (oldIndex && !lastToFirst && !firstToLast) {
duration = duration * Math.abs(oldIndex - index);
}
// If first to last or last to first we switch the order of the slides so that
// They are right next to each other at the front of the list
// Then perform the animation, Then put them back in their natural place without transitioning
// so it doesn't move for the user. Note count affects this differently
if (count < 3) {
if (lastToFirst && !reverse) {
switchSlide = old;
} else if (firstToLast) {
switchSlide = reverse ? slide : old;
}
} else {
if (lastToFirst) {
switchSlide = old;
} else if (firstToLast) {
switchSlide = slide;
}
}
// Set all slides to visible during the animation
this.setVisibility(null, true);
// Put the last item at the front of the list and reset the
// tracks (or the opposite for first to last)
if (switchSlide) {
switchSlide.element.style.order = "-1";
await this.translateTo(lastToFirst ? 0 : old.element.offsetLeft, 0);
}
// Perform the main sliding animation
await this.translateTo(slide.element.offsetLeft, duration);
// Set the order back to normal in the end
// Don't transtion so the slider seems like it doesn't jump/move
if (switchSlide) {
switchSlide.element.style.order = "0";
await this.translateTo(slide.element.offsetLeft, 0);
}
this.setVisibility(slide, false);
}
/**
* Handler for the entire fade transtion
*/
async fadeTransition({ slide, old }) {
this.setVisibility(null, true);
// Uses order trick to move the current slide
if (old) {
await this.fadeSlide(old, false);
old.element.style.order = "0";
}
slide.element.style.order = "-1";
await this.fadeSlide(slide, true);
this.setVisibility(slide, false);
}
/**
* Handler for the entire NO transtion
*/
noTransition({ slide, old }) {
this.setVisibility(slide, false);
if (old) {
old.element.style.order = "0";
}
slide.element.style.order = "-1";
return Promise.resolve();
}
goto(index, event, triggerType) {
const {
slide: old,
index:
oldIndex,
slides,
elements
} = this;
const isInit = triggerType === "init";
const slide = slides[index];
const activeClass = this.getClass("nav-button--active");
const transitionClass = this.getClass("transition", true);
const to = { slide, index, old, oldIndex, triggerType };
if (index === oldIndex) {
logWarning(this, "Could not goto slide, still performing transition");
return;
}
if (this.transitioning) {
logWarning(this, "Cancel goto(), same slide index as current slide");
return;
}
// Make all slide interactive elements inert
const lockInteractives = maintain.disabled({ context: this.elements.track });
this.transitioning = true;
// Set classes first just feels better
if (old) old.navButton.classList.remove(activeClass);
slide.navButton.classList.add(activeClass);
elements.container.classList.add(transitionClass);
// Perform transition and then set state
this.transition(to).then(() => {
this.index = index;
this.slide = slide;
this.transitioning = false;
elements.container.classList.remove(transitionClass);
lockInteractives.disengage();
if (!isInit) {
slide.element.focus();
this.emit("goto", [event, index, slide]);
}
});
}
setup() {
const { container, track, trackContainer } = this.elements;
const trackCss = trimWhitespace(this.trackCss());
const trackContainerStyles = trimWhitespace(this.trackContainerStyles());
const slideCss = trimWhitespace(this.slideCss());
track.setAttribute("style", trackCss);
trackContainer.setAttribute("style", trackContainerStyles);
this.slides.forEach(slide => {
slide.element.setAttribute("style", slideCss);
slide.element.setAttribute('tabindex', '-1');
});
container.classList.add(this.getClass());
if (this.options.swipeEnabled) {
this.setupSwipe();
}
}
setupSwipe() {
const images = this.elements.track.querySelectorAll("img");
// Cache for future destroy
// this.swipeInstance = setupSwipeListener(track, {
// preventScroll: true
// });
this.swipeListener = (event) => {
this.onSwipe(event);
}
this.swipeImageListener = (event) => {
event.preventDefault(); // Allow swiping on images
};
this.slides.forEach(slide => {
const { element } = slide;
slide.swipeInstance = setupSwipeListener(element, this.options.swipeOptions);
element.addEventListener("swipe", this.swipeListener);
});
images.forEach(image => {
image.addEventListener('dragstart', this.swipeImageListener);
});
}
onSwipe(event) {
const { directions } = event.detail;
const method = directions.left ? "next" : directions.right ? "previous" : null;
if (method) {
this[method](event);
}
}
trackContainerStyles() {
// Crop translated track
return `
overflow: hidden;
`;
}
transitionCss(property) {
const { transitionTimingFunction, transitionDuration } = this.options;
return `
transition-property: ${ property };
transition-duration: ${ cssDuration(transitionDuration) };
transition-timing-function: ${ transitionTimingFunction };
`;
}
trackCss() {
// Add in sliding transtion properties when not fade
return `
display: flex;
position: relative;
list-style: none;
${ this.transition === this.slideTransition ? this.transitionCss("transform") : "" }
`;
}
slideCss() {
const fadingTransition = this.transition === this.fadeTransition;
// Add in fading transtion properties when not slide (which is on the track)
return `
width: 100%;
flex: 0 0 100%;
${ fadingTransition ? this.transitionCss("opacity") : "" }
opacity: ${ fadingTransition ? "0" : "1" }
`;
}
getClass(child, modifier) {
const { namespace } = this.options;
if (modifier) {
return `${ namespace }--${ child }`;
} else if (child) {
return `${ namespace }__${ child }`;
} else {
return namespace;
}
}
createControlButton(action) {
const button = document.createElement("button");
button.classList.add(this.getClass("control-button"));
button.classList.add(this.getClass(`control-button--${ action }`));
button.classList.add(...this.options.buttonClasses);
button.setAttribute("data-slider-control", action);
button.setAttribute("type", "button");
button.innerHTML = this.getControlContent(action);
return button;
}
createControls(context) {
const controls = document.createElement('ul');
const previousItem = document.createElement("li");
const nextItem = document.createElement("li");
const previous = this.createControlButton("previous");
const next = this.createControlButton("next");
controls.classList.add(this.getClass("controls"));
previousItem.appendChild(previous);
nextItem.appendChild(next);
controls.appendChild(previousItem);
controls.appendChild(nextItem);
previous.addEventListener('click', this.previous.bind(this));
next.addEventListener('click', this.next.bind(this));
context.appendChild(controls);
return {
controls,
previousItem,
nextItem,
previous,
next
};
}
createNav(container) {
const nav = document.createElement("ul");
const navButtons = this.slides.map(this.createNavButton.bind(this));
const navItems = navButtons.map(button => {
const item = document.createElement("li");
item.appendChild(button);
// item.setAttribute('tabindex', "-1"); // WHY?
nav.appendChild(item);
return item;
});
nav.classList.add(this.getClass("nav"));
container.appendChild(nav);
return {
nav,
navButtons,
navItems
};
}
createNavButton(slide, index) {
const button = document.createElement("button");
button.classList.add(this.getClass("nav-button"));
button.setAttribute("type", "button");
button.innerHTML = this.getNavContent(slide);
slide.navButton = button; // Add reference to slide object
button.addEventListener("click", this.goto.bind(this, index));
return button;
}
getControlContent(action) {
const classes = this.options[action === "next" ? "iconClassNext" : "iconClassPrevious"];
return `
<span class="${ this.options.classAccessiblyHidden }">${ action }</span>
<span class="${ this.getClass("control-icon") } ${ classes }" aria-hidden="true"></span>
`;
}
getNavContent(slide) {
return `<span class="${ this.options.classAccessiblyHidden }">Item ${ slide.number }</span>`;
}
emit(name, args) {
if (this.options.events[name]) {
this.options.events[name].apply(this, args);
}
}
}