@tannerhodges/snap-slider
Version:
Simple JavaScript plugin to manage sliders using CSS Scroll Snap.
1,608 lines (1,398 loc) • 51.4 kB
JavaScript
// Dependencies
import elementClosest from 'element-closest';
import smoothscroll from 'smoothscroll-polyfill';
import tabbable from 'tabbable';
// Helpers
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
import getClosestAttribute from './helpers/getClosestAttribute';
import getElements from './helpers/getElements';
import getStyle from './helpers/getStyle';
import hasOwnProperty from './helpers/hasOwnProperty';
import isObject from './helpers/isObject';
import minmax from './helpers/minmax';
import on from './helpers/on';
import onReady from './helpers/onReady';
import passive from './helpers/passive';
import pick from './helpers/pick';
import qsa from './helpers/qsa';
import toArray from './helpers/toArray';
import values from './helpers/values';
// Modules
const logger = (process.env.NODE_ENV !== 'production')
? require('./modules/logger')
: {};
// Internal Variables
let counter = 1;
/**
* Snap Slider.
* @class
*/
class SnapSlider {
/**
* New Snap Slider.
*
* See `init()` for a full breakdown of `options`.
*
* @param {String|Element|Array|Object} containerOrOptions
* @param {Object} options
* @constructor
*/
constructor(containerOrOptions, options = {}) {
// Setup internal variables.
this.terms = {
prev: /(prev|back|before|left|up)/,
next: /(next|forward|after|right|down)/,
};
/* eslint-disable quote-props */
this.callbacks = {
'load': [],
'change': [],
'change.click': [],
'change.scroll': [],
'change.keydown': [],
'change.focusin': [],
'scroll': [],
'scroll.start': [],
'scroll.end': [],
};
/* eslint-enable quote-props */
this.init(containerOrOptions, options);
// Don't construct sliders with empty containers.
if (!this.container) {
return;
}
this.watchForChanges();
// Keep track of the slider so we can reference & debug it later.
this.container.SnapSlider = this;
window._SnapSliders[this.id] = this;
}
/**
* Initialize this slider.
*
* @param {String|Element|Array|Object} containerOrOptions
* @param {Object} options
* @param {String|Element|Array} options.container
* @param {String} options.id
* @param {String|Element|Array} options.slides
* @param {String|Number} options.start
* @param {String|Element|Array} options.nav
* @param {String|Element|Array} options.buttons
* @param {String|Element|Array} options.prev
* @param {String|Element|Array} options.next
* @return {void}
*/
init(containerOrOptions, options = {}) {
// Allow people to quickly spin up sliders by just passing a container
// element, or by passing in a single options object.
if (isObject(containerOrOptions)) {
options = containerOrOptions;
}
// Fill default options.
this.options = {
container: containerOrOptions,
id: '',
slides: '',
nav: '',
buttons: '',
prev: '',
next: '',
start: 0,
loop: null,
on: {},
...options,
};
// Get single element from params.
const container = getElements(this.options.container).shift();
// Don't construct sliders with empty containers.
if (!container) {
if (process.env.NODE_ENV !== 'production') {
console.log(`
🚫 Whoops! Snap Slider can't find a container element matching "${this.options.container}".\n
🔍 Please check your selectors for typos. Make sure the element actually exists
in the DOM when Snap Slider tries to initialize it.\n
👇 Here's a copy of the options you tried to initialize with for debugging:\n\n`, this.options, '\n\n');
}
return;
}
// Great! Now let's start initializing everything.
this.container = container;
// Get selectors from JavaScript or data attributes.
this.options.buttons = options.buttons || this.container.getAttribute('data-snap-slider-buttons');
this.options.prev = options.prev || this.container.getAttribute('data-snap-slider-prev');
this.options.next = options.next || this.container.getAttribute('data-snap-slider-next');
// Get and set persistent options in data attributes.
this.id = this.getMaybeSetID(container, this.options.id);
this.slides = this.getMaybeSetSlides(container, this.options.slides);
this.align = this.getMaybeSetAlign(container, this.options.align);
this.current = this.getMaybeSetStart(container, this.options.start);
this.loop = this.getMaybeSetLoop(container, this.options.loop);
// Reset internal variables.
this.transition = null;
this.scrolling = false;
// Add custom callbacks.
// eslint-disable-next-line no-restricted-syntax
for (const eventName in this.options.on) {
if (hasOwnProperty(this.options.on, eventName)) {
this.on(eventName, this.options.on[eventName]);
}
}
// Setup navigation.
// NOTE: If left blank, `addNav()` will handle the fallbacks for button selectors.
const navOptions = pick(this.options, [
'buttons',
'prev',
'next',
]);
// Init custom goto buttons in the container.
// NOTE: "Goto" buttons are automatically handled by delegated click
// events on the `body`. For more details, see `handleGotoClick()`.
this.addGotoButtons({ ...navOptions, container });
// Init standard navs with data attributes.
this.addNav(`[data-snap-slider-nav="${this.id}"]`, navOptions);
// Then init custom navs too.
if (this.options.nav) {
this.addNav(this.options.nav, navOptions);
}
// Go to the slide we want to start on.
this.update();
}
/**
* Get and maybe set a slider's ID on the closest container element.
*
* If no ID was specified, generates a fallback ID.
*
* @param {Element} container
* @param {String} id
* @return {String}
*/
getMaybeSetID(container, id) {
// Either use the ID we were given or the ID already on the container.
id = id
|| container.getAttribute('data-snap-slider')
|| container.id;
// If we don't have an ID, make one up and increment our internal
// counter for the next slider.
if (!id) {
id = `slider-${counter}`;
counter += 1;
}
// Store value in data attribute.
container.setAttribute('data-snap-slider', id);
// Return the final ID.
return id;
}
/**
* Get all slide elements for a given container.
*
* Defaults to container's children.
*
* @param {Element} container
* @param {String} selector
* @return {Array}
*/
getMaybeSetSlides(container, selector) {
// Get selector from JavaScript or data attribute.
selector = selector && typeof selector === 'string'
? selector
: container.getAttribute('data-snap-slider-slides');
// Store value in data attribute.
container.setAttribute('data-snap-slider-slides', selector || '');
// If selector exists, use those elements. Otherwise,
// assume the container's immediate children are slides.
const slides = selector
? getElements(selector, container)
: toArray(container.children);
// Ensure all slides are focusable but not tabbable.
slides.forEach((slide) => slide.setAttribute('tabindex', '-1'));
// Return array of slides.
return slides;
}
/**
* Get alignment fallback for a given container.
*
* @param {Element} container
* @param {String} align
* @return {String}
*/
getMaybeSetAlign(container, align) {
// Get align index from JavaScript, data attribute, or leave blank.
align = align || container.getAttribute('data-snap-slider-align') || '';
// Store value in data attribute.
container.setAttribute('data-snap-slider-align', align);
return align;
}
/**
* Get start index for a given container.
*
* Defaults to 1.
*
* @param {Element} container
* @param {String} start
* @return {String|Number}
*/
getMaybeSetStart(container, start) {
// Get start index from JavaScript, data attribute, or default to 1.
if (!SnapSlider.isValidIndex(start)) {
start = container.getAttribute('data-snap-slider-start') || 1;
}
// Store value in data attribute.
container.setAttribute('data-snap-slider-start', start);
return start;
}
/**
* Get and maybe set a slider's `loop` option on the closest container element.
*
* @param {Element} container
* @param {Boolean} loop
* @return {String}
*/
getMaybeSetLoop(container, loop) {
// If we were given a Boolean value to set, use that.
// Else check for an existing data attribute.
// Defaults to `false`.
loop = typeof loop === 'boolean'
? loop
: container.getAttribute('data-snap-slider-loop') === 'true';
// Store value in data attribute.
container.setAttribute('data-snap-slider-loop', loop);
// Return the final loop value.
return loop;
}
/**
* Get the `scroll-snap-align` for a snap slider element.
*
* Falls back to `data-snap-slider-align` when no CSS
* is detected, otherwise defaults to `start`.
*
* @param {Element} el
* @return {String}
*/
getSnapAlign(el) {
// Get element's CSS align value.
const style = getStyle(el, 'scrollSnapAlign');
// If browser supports Scroll Snap and slide
// has a non-empty value, return it.
if (style && style.indexOf('none') < 0) {
return style;
}
// Otherwise, fallback to the slider's align attribute.
// Else assume "start" for everything.
return getClosestAttribute(el, 'data-snap-slider-align')
|| 'start';
}
/**
* Get a specific slide element. Accepts any valid goto alias.
*
* @param {Number} index Starts at 1.
* @return {Element}
*/
getSlide(index) {
// Convert index aliases to numbers.
index = this.getIndexNumber(index);
// Return the slide for that numeric index.
// NOTE: Subtract 1 because the array is 0-index, but our API is 1-index.
return this.slides[index - 1];
}
/**
* Get the current slide element.
*
* @return {Element}
*/
getCurrentSlide() {
// NOTE: Subtract 1 because the array is 0-index, but our API is 1-index.
return this.slides[this.current - 1];
}
/**
* Is this a valid index?
*
* - first
* - middle
* - last
* - prev
* - next
*
* @param {String|Number} index
* @return {Number}
*/
static isValidIndex(index) {
const aliases = [
'first',
'middle',
'last',
'prev',
'next',
];
// Valid indexes are either a known alias,
// or a positive integer.
return aliases.indexOf(index) >= 0
|| parseInt(index, 10) >= 1;
}
/**
* Get the slide number for any index.
*
* Returns -1 if index is invalid.
*
* @param {String|Number} index
* @return {Number}
*/
getIndexNumber(index) {
let num;
if (index === 'first') {
// Get the first slide.
num = 1;
} else if (index === 'middle') {
// Get the middle slide.
num = Math.ceil(this.slides.length / 2);
} else if (index === 'last') {
// Get the last slide.
num = this.slides.length;
} else if (index === 'prev') {
// Get the previous slide.
num = this.current - 1;
} else if (index === 'next') {
// Get the next slide.
num = this.current + 1;
} else {
// Try to get a number.
num = parseInt(index, 10) || -1;
}
if (this.loop) {
// If we're looping, send out-of-bounds requests
// to the other end of the slider.
if (num < 1) {
num = this.slides.length;
}
if (num > this.slides.length) {
num = 1;
}
} else if (num < 1 || num > this.slides.length) {
// Otherwise, ignore out-of-range indexes.
num = -1;
}
// Return numeric index. Or, if something goes wrong,
// fallback to the first slide.
return num || 1;
}
/**
* Get the offset we should scroll to for a specific slide.
*
* @param {Element} slide
* @return {Object} { top, left }
*/
getScrollOffset(slide) {
const { container } = this;
const align = this.getSnapAlign(slide);
// Calculate the 'start' position by default.
// NOTE: This forces slides with align `none` to still snap into place.
let top = slide.offsetTop;
let left = slide.offsetLeft;
// NOTE: Because Safari uses the 2-value syntax, we simply check for matching
// keywords. If this causes incorrect behavior, use the `data-snap-slider-align`
// attribute to override our automatic CSS detection.
if (align.indexOf('center') >= 0) {
// To center a slide, start with its beginning offset (the 'start' position).
// Then add half the slide's size minus half the container size.
top = slide.offsetTop + slide.offsetHeight / 2 - container.offsetHeight / 2;
left = slide.offsetLeft + slide.offsetWidth / 2 - container.offsetWidth / 2;
} else if (align.indexOf('end') >= 0) {
// To align the end of a slide, start with its beginning offset (the 'start' position).
// Then subtract the size of the container, but add back the size of the slide.
top = slide.offsetTop - container.offsetHeight + slide.offsetHeight;
left = slide.offsetLeft - container.offsetWidth + slide.offsetWidth;
}
// Keep offsets within the scrollable area.
top = minmax(top, 0, container.scrollHeight);
left = minmax(left, 0, container.scrollWidth);
return { top, left };
}
/**
* Go to a slide.
*
* @param {String|Number} index Starts at 1.
* @param {Object} options
* @param {Boolean} options.focus
* @param {Boolean} options.force
* @param {Boolean} options.ignoreCallbacks
* @param {Boolean} options.immediate
* @param {Event} event
* @return {Boolean}
*/
goto(index, options = {}, event) {
// Fill default options.
options = {
// By default, focus the slide we're going to.
focus: true,
// Force-update the scroll position, even if we're already on the current slide.
force: false,
// Ignore custom callbacks for events.
ignoreCallbacks: false,
// Immediately update position without smooth scrolling.
immediate: false,
...options,
};
// Get the next slide we should go to.
const next = this.getIndexNumber(index);
// If nothing changed, don't do anything (as long as
// we're not trying to force it).
if (!options.force && next === this.current) {
return false;
}
// Get the next slide.
const slide = this.getSlide(next);
if (!slide) {
return false;
}
// Scroll to it!
const { top, left } = this.getScrollOffset(slide);
if (options.immediate) {
// Scroll immediately.
this.container.scroll({ top, left });
} else {
// Let the event handlers know we're coming, then smooth scroll.
this.startTransition(next);
this.container.scroll({ top, left, behavior: 'smooth' });
}
// Update state.
this.current = next;
// We changed slides!
this.fireEvent('change', event, options);
return true;
}
/**
* Build the `goto` attribute for a nav button.
*
* @param {Element|Boolean} nav
* @param {String|Number} index
* @return {String}
*/
buildGoto(nav, index = '') {
// Start with an empty string.
let goto = '';
// If this button isn't part of a nav, include the slider ID.
if (!nav) {
goto += `${this.id}:`;
}
// Add the index and return.
return goto + index;
}
/**
* Set the `goto` attribute for nav buttons.
*
* @param {String|Element|Array} buttons
* @param {String} index
* @return {void}
*/
setGoto(buttons, index) {
buttons = getElements(buttons);
// If we found custom prev/next buttons, set their `goto` attributes
// before we loop through the rest of the buttons.
buttons.forEach((button) => {
button.setAttribute('data-snap-slider-goto', this.buildGoto(
// Don't assume this button is grouped with the others. It may
// be somewhere else on the page, so double check for a parent
// slider or nav container.
button.closest('[data-snap-slider], [data-snap-slider-nav]'),
index,
));
});
}
/**
* Get the slider ID and slide index a goto button is targeting.
*
* NOTE: This method is static so we can call it in the delegated body
* click events. For more details, see `handleGotoClick()`.
*
* @param {String|Element|Array} button
* @return {Object} { sliderID, index }
*/
static getButtonTarget(button) {
// Where are we going?
const goto = button ? button.getAttribute('data-snap-slider-goto') : '';
// Ignore missing buttons and attributes.
if (!goto) {
return {};
}
// Parse slide index and slider ID from `goto` attribute.
const args = goto.split(':').map((str) => str.trim());
const index = args.pop();
let sliderID = args.pop();
// If the slider ID wasn't included, check for a parent nav or container element.
if (!sliderID) {
const nav = button.closest('[data-snap-slider-nav]');
const container = button.closest('[data-snap-slider]');
// If it is in a nav or container, get the slider ID from there.
if (nav) {
sliderID = nav.getAttribute('data-snap-slider-nav');
}
if (container) {
sliderID = container.getAttribute('data-snap-slider');
}
}
// If there's still no slider ID, is this button already in a slider?
if (!sliderID) {
const slider = button.closest('data-snap-slider');
// If it is in a slider, get the slider ID from there.
if (slider) {
sliderID = slider.getAttribute('data-snap-slider');
}
}
return { sliderID, index };
}
/**
* Handle click events for nav (aka "goto") buttons.
*
* By delegating events to the body, we can automatically
* handle dynamic goto buttons (i.e., without having to
* reinitialize slider events).
*
* @param {Event} event
* @return {void}
*/
static handleGoto(event) {
// Get the button we clicked.
const button = event.target.closest('[data-snap-slider-goto]');
// Get the slider we're trying to update.
const { sliderID, index } = SnapSlider.getButtonTarget(button);
const slider = window._SnapSliders[sliderID];
// Make sure it actually exists.
if (!slider) {
return;
}
// Go! But only focus the slide if we're NOT clicking a prev/next button.
slider.goto(index, null, event);
}
/**
* Start transitioning to another slide.
*
* This way when you click a nav button, the current slide updates
* immediately but the scroll listener doesn't override it, or fire
* extra change events.
*
* @param {Number} next
* @return {void}
*/
startTransition(next) {
// Tell the scroll listener which slide we're transitioning to.
this.transition = {
from: this.current,
to: next,
diff: Math.abs(next - this.current),
};
// In case someone's fast enough to start scrolling again before our
// scroll listener resolves the `transition` flag, or if the slide's
// already visible and nothing actually has to scroll,
// set a timeout to resolve the transition.
const stuck = this.transition.to;
// If there's already a check waiting, clear it to avoid accidentally
// reverting to the wrong slide.
if (this.checkTransition) {
clearTimeout(this.checkTransition);
}
// Now make sure we don't get stuck!
this.checkTransition = setTimeout(() => {
if (this.transition.to === stuck) {
this.stopTransition();
}
}, 1000);
}
/**
* Stop the transitions! Set things back to normal.
*
* @return {void}
*/
stopTransition() {
// Clear transition checks.
this.transition = null;
clearTimeout(this.checkTransition);
}
/**
* Is this a "previous" button?
*
* @param {String|Element|Array} button
* @return {Boolean}
*/
isPrevButton(button) {
button = getElements(button).shift();
// Ignore missing elements.
if (!button) {
return false;
}
// Check whether the `goto` attribute is "prev".
// If not, check the text & class for common "prev" terms.
return (button.getAttribute('data-snap-slider-goto') || '').match(/\bprev$/)
|| button.textContent.toLowerCase().match(this.terms.prev)
|| button.className.toLowerCase().match(this.terms.prev);
}
/**
* Is this a "next" button?
*
* @param {String|Element|Array} button
* @return {Boolean}
*/
isNextButton(button) {
button = getElements(button).shift();
// Ignore missing elements.
if (!button) {
return false;
}
// Check whether the `goto` attribute is "next".
// If not, check the text & class for common "next" terms.
return (button.getAttribute('data-snap-slider-goto') || '').match(/\bnext$/)
|| button.textContent.toLowerCase().match(this.terms.next)
|| button.className.toLowerCase().match(this.terms.next);
}
/**
* Is this index a relative term? I.e., is it `prev` or `next`?
*
* @param {String|Number} index
* @return {Boolean}
*/
static isRelative(index) {
return index === 'prev' || index === 'next';
}
/**
* Does an index match the current slide?
*
* @param {String|Number} index
* @return {Boolean}
*/
isCurrent(index) {
// Ignore relative indexes (i.e., `prev` and `next`) since they
// always refer to one more or less than the current index.
if (SnapSlider.isRelative(index)) {
return false;
}
// Does this numeric index match the current slide?
return this.getIndexNumber(index) === this.current;
}
/**
* Add goto buttons for the current slider.
*
* @param {String|Element|Array|Object} buttonsOrOptions
* @param {Object} options
* @param {String|Element|Array} options.container
* @param {String|Element|Array} options.buttons
* @param {String|Element|Array} options.prev
* @param {String|Element|Array} options.next
* @return {Boolean}
*/
addGotoButtons(buttonsOrOptions, options = {}) {
// Allow people to quickly add nav buttons by just passing the
// selector, or by passing in a single options object.
if (isObject(buttonsOrOptions)) {
options = buttonsOrOptions;
}
// Fill default options.
options = {
container: '',
buttons: buttonsOrOptions,
prev: '',
next: '',
...options,
};
// Get button elements.
// NOTE: If someone passes an overly-generic selector (e.g., `button`)
// this will query the entire document. In general, you should either
// specify a container element, use specific selectors, or pass
// the elements directly.
const buttons = getElements(options.buttons, options.container);
const prev = getElements(options.prev, options.container);
const next = getElements(options.next, options.container);
// If we found custom prev/next buttons, set their `goto` attributes
// before we loop through the rest of the buttons.
prev.forEach((b) => b.hasAttribute('data-snap-slider-goto') || this.setGoto(prev, 'prev'));
next.forEach((b) => b.hasAttribute('data-snap-slider-goto') || this.setGoto(next, 'next'));
// Keep track of the index outside of the loop so we can
// skip prev/next buttons but still go in order.
let nextIndex = 1;
// Loop through the buttons and set each one's `goto` attribute.
buttons.forEach((button) => {
// Ignore buttons that already have a `goto` attribute.
if (button.hasAttribute('data-snap-slider-goto')) {
return null;
}
// Previous
if (this.isPrevButton(button)) {
return this.setGoto(button, 'prev');
}
// Next
if (this.isNextButton(button)) {
return this.setGoto(button, 'next');
}
// Numeric: Check the text for a number, else fallback to the next index.
const index = parseInt(button.textContent.replace(/.*\b(\d+)\b.*/, '$1'), 10) || nextIndex;
// Increment the next index.
nextIndex = index + 1;
return this.setGoto(button, index);
});
this.updateButtons();
return true;
}
/**
* Get navs for the current slider.
*
* @return {Array}
*/
getNavs() {
// eslint-disable-next-line arrow-body-style
return qsa('[data-snap-slider-nav]').filter((nav) => {
// Only return navs targeting the current slider.
return nav.getAttribute('data-snap-slider-nav') === this.id;
});
}
/**
* Get nav buttons for the current slider.
*
* @return {Array}
*/
getButtons() {
return qsa('[data-snap-slider-goto]').filter((button) => {
const { sliderID } = SnapSlider.getButtonTarget(button);
// Only return buttons targeting the current slider.
return sliderID === this.id;
});
}
/**
* Update nav buttons for the current slider.
*
* @return {void}
*/
updateButtons() {
// Wait until the slider has initialized.
if (!this.current) {
return;
}
// Loop through all the nav buttons.
this.getButtons().forEach((button) => {
// Figure out which slide it's for...
const { index } = SnapSlider.getButtonTarget(button);
// And update its class.
if (this.isCurrent(index)) {
button.classList.add('is-current');
} else {
button.classList.remove('is-current');
}
// Also, enable/disable relative buttons unless `loop` is on.
if (!this.loop && SnapSlider.isRelative(index)) {
// Disable prev button on first slide.
// Disable next button on last slide.
const disabled = (index === 'prev' && this.current === 1)
|| (index === 'next' && this.current === this.slides.length);
if (disabled) {
// button.setAttribute('disabled', '');
button.classList.add('is-disabled');
} else {
// button.removeAttribute('disabled', '');
button.classList.remove('is-disabled');
}
}
});
}
/**
* Update slide active states when the slider changes.
*
* @return {void}
*/
updateSlides() {
this.slides.forEach((slide, index) => {
// NOTE: Subtract 1 because the array is 0-index, but our API is 1-index.
if (index === this.current - 1) {
slide.classList.add('is-current');
slide.removeAttribute('aria-hidden');
// Enable tabbing for current slide
qsa('[data-snap-slider-tabindex]', slide).forEach((tab) => {
tab.removeAttribute('tabindex');
});
} else {
slide.classList.remove('is-current');
slide.setAttribute('aria-hidden', 'true');
// Disable tabbing for non-current slides
tabbable(slide).forEach((tab) => {
tab.setAttribute('tabindex', '-1');
tab.setAttribute('data-snap-slider-tabindex', '');
});
}
});
}
/**
* Add a nav element for the current slider. Automatically hooks up any nav
* buttons inside the nav.
*
* @param {String|Element|Array|Object} containerOrOptions
* @param {Object} options
* @param {String|Element|Array} options.container
* @param {String|Element|Array} options.buttons
* @param {String|Element|Array} options.prev
* @param {String|Element|Array} options.next
* @return {Boolean}
*/
addNav(containerOrOptions, options = {}) {
// Allow people to quickly add a nav by just passing a container
// element, or by passing in a single options object.
if (isObject(containerOrOptions)) {
options = containerOrOptions;
}
// Fill default options.
options = {
container: containerOrOptions,
buttons: '',
prev: '',
next: '',
...options,
};
// Get matching nav containers.
const navContainers = getElements(options.container);
// Don't add navs without container elements.
if (!navContainers.length) {
return false;
}
navContainers.forEach((navContainer) => {
// Set a data attribute assigning the nav to this slider.
navContainer.setAttribute('data-snap-slider-nav', this.id);
// Get button selectors from JavaScript, data attribute, or default to 'button'.
// NOTE: In this case, allow the nav's data attribute to override the parent
// container's options.
const buttons = navContainer.getAttribute('data-snap-slider-buttons')
|| options.buttons
|| 'button';
const prev = options.prev || navContainer.getAttribute('data-snap-slider-prev');
const next = options.next || navContainer.getAttribute('data-snap-slider-next');
// And add them.
this.addGotoButtons({
container: navContainer,
buttons,
prev,
next,
});
});
return true;
}
/**
* Which slide is closest to its active offset position?
*
* Returns an object include the slide's index, element,
* and the diff between its active offset and our
* current scroll position.
*
* @return {Object} { index, slide, diff }
*/
getClosest() {
return this.slides.reduce((prev, slide, index) => {
// 1-index to stay consistent with our API.
index += 1;
// How far away are we from the next slide's active offset position?
const offset = this.getScrollOffset(slide);
const diff = {
top: Math.abs(this.container.scrollTop - offset.top),
left: Math.abs(this.container.scrollLeft - offset.left),
};
// Save the next slide's info to compare with other slides.
const next = { index, slide, diff };
// If this is the first slide, return it and compare the next one.
if (!prev) {
return next;
}
// Compare each slide to see which one is the closest to its active offset position.
// As soon as the next slide is at least as close as the previous one, return it.
if (next.diff.left <= prev.diff.left && next.diff.top <= prev.diff.top) {
return next;
}
// Otherwise, keep the last closest slide.
return prev;
// Init with `false` so the first slide gets processed just like the rest of them.
}, false);
}
/**
* Watch the container scroll for when the current slide changes.
*
* @return {void}
*/
watchForChanges() {
// Scroll listener. Save so we can remove it during `destroy()`.
this.scrollListener = throttle((event) => {
// Which slide is closest to their active offset position?
const closest = this.getClosest();
// If someone's passively scrolling (i.e., not in a transition),
// then as soon as we've scrolled to another slide, mark that
// slide as the new current one and fire a change event.
if (!this.transition && closest.index !== this.current) {
this.current = closest.index;
this.fireEvent('change', event);
}
// If we just started scrolling, update state and
// fire a `scroll.start` event.
if (!this.scrolling) {
this.scrolling = true;
this.fireEvent('scroll.start', event);
}
// Fire a generic `scroll` event.
this.fireEvent('scroll', event);
}, 250);
// Scroll end listener. Save so we can remove it during `destroy()`.
this.scrollEndListener = debounce((event) => {
// We're done scrolling!
this.scrolling = false;
this.fireEvent('scroll.end', event);
// Clear any previous transition checks.
// NOTE: This has to happen *after* we fire the `scroll.end` event,
// otherwise `handleFocus` won't be able to access `this.transition`.
this.stopTransition();
}, 250);
// Arrow key listener. Save so we can remove it during `destroy()`.
this.arrowKeyListener = throttle((event) => {
// Ignore events that have already been prevented.
if (event.defaultPrevented) {
return;
}
// Listen for arrow keys.
// @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
const isPrev = ['Up', 'ArrowUp', 'Left', 'ArrowLeft'].indexOf(event.key) >= 0;
const isNext = ['Down', 'ArrowDown', 'Right', 'ArrowRight'].indexOf(event.key) >= 0;
// Ignore non-arrow keys.
if (!isPrev && !isNext) {
return;
}
// Go to the next or previous slide.
this.goto(isNext ? 'next' : 'prev', null, event);
// Prevent default browser scroll.
event.preventDefault();
}, 250);
// Focus listener. Save so we can remove it during `destroy()`.
this.focusListener = (event) => {
// Only trigger `goto` on focus when we're not passively scrolling.
// However, if someone manually triggered a transition then
// allow them to click or tab away to a different slide.
if (this.scrolling && !this.transition) {
return;
}
// Get slide + index.
let slide;
let index;
this.slides.forEach((s, i) => {
if (s.contains(event.target)) {
slide = s;
index = i + 1;
}
}, null);
// If there's a matching slide, go to it.
if (slide) {
this.goto(index, null, event);
}
};
// Resize Observer. Save so we can disconnect it during `destroy()`.
// Only init if browser supports it, else fallback to noop.
this.resizeObserver = { observe: () => {}, disconnect: () => {} };
if ('ResizeObserver' in window) {
this.resizeObserver = new ResizeObserver(this.resizeCallback.bind(this));
}
// Add all our listeners.
// Set timeout to avoid initial `goto` event triggering a scroll listener.
setTimeout(() => {
this.container.addEventListener('scroll', this.scrollListener, passive);
this.container.addEventListener('scroll', this.scrollEndListener, passive);
this.container.addEventListener('keydown', this.arrowKeyListener);
this.container.addEventListener('focusin', this.focusListener);
this.resizeObserver.observe(this.container);
// Done loading!
this.fireEvent('load');
}, 100);
}
/**
* Update the slider on load.
*
* @return {void}
*/
hasLoaded() {
this.container.classList.add('has-loaded');
}
/**
* Update this slider (e.g., on resize). Basically just repositions the
* current slide.
*
* @return {void}
*/
update() {
// Make sure we're still on the current slide.
this.goto(this.current, {
focus: false,
force: true,
ignoreCallbacks: true,
immediate: true,
});
}
/**
* Destroy this slider. Stop any active transitions, remove its event
* listeners, and delete it from our internal array of slider instances.
*
* @return {void}
*/
destroy() {
// Stop running transitions, event listeners, etc.
this.stopTransition();
this.container.removeEventListener('scroll', this.scrollListener);
this.container.removeEventListener('scroll', this.scrollEndListener);
this.container.removeEventListener('keydown', this.arrowKeyListener);
this.resizeObserver.disconnect();
// Reset callbacks.
// eslint-disable-next-line no-restricted-syntax
for (const eventName in this.callbacks) {
if (hasOwnProperty(this.callbacks, eventName)) {
this.callbacks[eventName] = [];
}
}
// Remove references to this slider.
delete this.container.SnapSlider;
delete window._SnapSliders[this.id];
}
/**
* Reset this slider (e.g., after adding or removing a slide).
*
* See `init()` for a full breakdown of `options`.
*
* @param {Object} options
* @return {void}
*/
reset(options = {}) {
// Copy initial options.
const initialOptions = this.options;
// Remove initial callbacks to avoid duplicating them.
delete initialOptions.on;
// Don't let people reset critical options during reset (e.g., slider ID).
delete options.container;
delete options.id;
// Re-initialize this slider with initial options + overrides.
this.init(this.container, { ...initialOptions, ...options });
}
/**
* Handle resize observer events.
*
* @return {void}
*/
resizeCallback() {
this.update();
}
/**
* When an event happens, fire all the callback functions for that event.
*
* @param {String} eventName
* @param {Event} event
* @param {Object} options
* @param {Boolean} options.focus
* @param {Boolean} options.ignoreCallbacks
* @return {void}
*/
fireEvent(eventName, event, options = {}) {
// Ignore invalid events.
if (!hasOwnProperty(this.callbacks, eventName)) {
return;
}
// Fill default options.
options = {
// By default, focus the slide we're going to.
focus: true,
// Ignore custom callbacks for events.
ignoreCallbacks: false,
...options,
};
// Required: Update slider attributes on load.
if (eventName === 'load') {
this.hasLoaded();
}
// Required: Update buttons and slides on every change.
if (eventName === 'change') {
this.updateButtons();
this.updateSlides();
}
// Allow focus events to be ignored.
if (options.focus) {
this.handleFocus(eventName, event);
}
// Allow callbacks to be ignored.
if (options.ignoreCallbacks) {
return;
}
// Fallback object for `null` events.
event = event || {};
// Include more granular event types for easier callbacks.
const events = [eventName];
if (hasOwnProperty(this.callbacks, `${eventName}.${event.type}`)) {
events.push(`${eventName}.${event.type}`);
}
// Fire all the callbacks for each event.
events.forEach((name) => {
this.callbacks[name].forEach((callback) => {
if (typeof callback === 'function') {
callback(this, event);
}
});
});
}
/**
* Handle focus events differently depending on whether we're manually
* triggering changes or passively scrolling.
*
* @param {String} eventName
* @param {Event} event
* @return {void}
*/
handleFocus(eventName, event) {
// Only handle focus for manually triggered changes (e.g., clicks and key presses).
// Ignore passive scrolling to avoid mistakenly hijacking someone's focus.
if (!this.transition) {
return;
}
// Only focus the slide if we're NOT clicking a prev/next button.
if (event && eventName === 'change') {
// Did we click a button?
const button = event.target.closest('[data-snap-slider-goto]');
const { index } = SnapSlider.getButtonTarget(button);
// If we clicked a relative button, get out.
if (SnapSlider.isRelative(index)) {
return;
}
}
// If we're only transitioning one slide over, focus immediately on change.
if (this.transition.diff <= 1 && eventName === 'change') {
this.getCurrentSlide().focus({ preventScroll: true });
}
// If we're transitioning across multiple slides, wait until the scroll ends to focus.
// Otherwise, we'll cause the scroll to flicker.
if (this.transition.diff > 1 && eventName === 'scroll.end') {
// Only focus the slide if we haven't already focused on another
// element during the transition.
if (!document.activeElement
|| document.activeElement === document.body
|| document.activeElement.closest('[data-snap-slider-goto]')) {
this.getCurrentSlide().focus({ preventScroll: true });
}
}
}
/**
* Add callbacks to fire on specific events.
*
* @param {String} event Event name.
* @param {Function} callback Function w/ slider and event params (e.g., `fn(slider, event)`).
* @return {void}
*/
on(event, callback) {
// Ignore invalid events.
if (!hasOwnProperty(this.callbacks, event)) {
if (process.env.NODE_ENV !== 'production') {
console.log(`
🚫 Whoops! Snap Slider can't add events for "${event}".\n
📝 Please make sure your event matches one of the ones in this list:\n\n`, Object.keys(this.callbacks), '\n\n');
}
return;
}
// Ignore invalid callbacks.
if (typeof callback !== 'function') {
if (process.env.NODE_ENV !== 'production') {
console.log(`
🚫 Whoops! Snap Slider can only add functions as callbacks.\n
👀 It looks like you passed a "${typeof callback}" instead.\n\n`, callback, '\n\n');
}
return;
}
// Add the callback for our event.
this.callbacks[event].push(callback);
}
/**
* Log that we couldn't find the element you're looking for.
*
* @param {mixed} element
* @return {void}
*/
static notFound(element) {
if (process.env.NODE_ENV !== 'production') {
console.log(`
😢 Oh no! Snap Slider couldn't find a slider for "${element}".\n
-------------------------------------------------------------------------------------------
ℹ️ NOTE: Make sure the elements you're trying to debug have a \`data-snap-slider\` attribute.
-------------------------------------------------------------------------------------------\n\n`);
}
}
/**
* Get the `SnapSlider` object for a slider based on its ID.
*
* @param {String} id
* @return {SnapSlider}
*/
static get(id) {
return window._SnapSliders[id];
}
/**
* `console.log` info about a slider, its nav, or goto buttons.
*
* @param {String|Element|Array} idOrElements
* @return {void}
*/
static debug(idOrElements) {
if (process.env.NODE_ENV !== 'production') {
/* eslint-disable no-irregular-whitespace */
let elements = [];
// 1. Debug all sliders by default.
if (arguments.length === 0) {
idOrElements = '[data-snap-slider]';
}
// 2. Debug a slider by its ID.
if (typeof idOrElements === 'string'
&& hasOwnProperty(window._SnapSliders, idOrElements)) {
idOrElements = `[data-snap-slider="${idOrElements}"]`;
}
// 3. Debug slider elements.
elements = getElements(idOrElements);
if (!elements.length) {
SnapSlider.notFound(idOrElements);
return;
}
// Debug all the things!
elements.forEach((el, i) => {
// What are we debugging? Is this a button, nav, or container?
const button = el.closest('[data-snap-slider-goto]');
const nav = el.closest('[data-snap-slider-nav]');
const container = el.closest('[data-snap-slider]');
// If we're debugging more than one element at a time,
// add the index # to each section heading.
const num = elements.length > 1 ? `#${i + 1} ` : '';
// 2a. Buttons
if (button) {
// Get details for the target slider & slide.
const target = SnapSlider.getButtonTarget(button);
const slider = window._SnapSliders[target.sliderID];
// Make sure slide index is valid.
const { index } = target;
const slideIndex = SnapSlider.isValidIndex(index)
? `"${index}"`
: `🚫 "${index}" - Yikes! This index is invalid.\n\nUse a positive number instead, or one of the following aliases:\n
${['first', 'middle', 'last', 'prev', 'next'].map((a) => `• ${a}`).join('\n')}`;
// "We couldn't find anything."
const sliderID = target.sliderID
? `"${target.sliderID}"`
: `🤷♀️ We couldn't find any.\n
• Make sure your button is inside a \`data-snap-slider-nav\` element, or...
• Include the slider ID you want to target in your \`data-snap-slider-goto\` attribute.
◦ For example, \`data-snap-slider-goto="example-slider:${target.index || 'prev'}"\`.`;
let sliderContainer = `🤷♀️ We couldn't find any.\n
• Double check that your slider ID is correct (👆).
• Make sure your slider has the same ID in its \`data-snap-slider\` attribute.
◦ For example, \`data-snap-slider="example-slider"\`.`;
let slideIndexNumber = `🤷♀️ We couldn't find any.\n
• Double check that your index is valid (👆).
• Make sure a slide actually exists at that index (👇).`;
let slide = `🤷♀️ We couldn't find any.\n
• Double check that your index is valid (👆).
• Make sure a slide actually exists at that index.
• Make sure your slider recognizes the slide element as a slide.`;
// We found it!
if (slider && slider.container) {
sliderContainer = slider.container;
slideIndexNumber = slider.getIndexNumber(index);
slide = slider.getSlide(index);
}
// Log 'em.
return logger.section({
heading: `🕹 Button ${num}`,
description: button,
groups: [
{
heading: '1. What slider is this button targeting?',
items: [
{ heading: 'Slider ID', description: sliderID },
{ heading: 'Slider Element', description: sliderContainer },
{ heading: 'Slider Object', description: slider || "🤷♀️ We couldn't find any." },
],
},
{
heading: '2. Which slide will it go to?',
items: [
{ heading: 'Slide Index', description: slideIndex },
{ heading: 'Slide Index (Number)', description: slideIndexNumber },
{ heading: 'Slide Element', description: slide },
],
},
],
collapsed: true,
});
}
// 2b. Navigation
if (nav) {
// Get details for the target slider
let sliderID = nav.getAttribute('data-snap-slider-nav');
const slider = window._SnapSliders[sliderID];
let buttons = qsa('[data-snap-slider-goto]', nav);
// "We couldn't find anything."
sliderID = sliderID
? `"${sliderID}"`
: `🤷♀️ We couldn't find any.\n
• Include the slider ID you want to target in your \`data-snap-slider-nav\` attribute.
◦ For example, \`data-snap-slider-nav="example-slider"\`.`;
let sliderContainer = `🤷♀️ We couldn't find any.\n
• Make sure the ID in your container's \`data-snap-slider\` attribute and the ID in your nav's \`data-snap-slider-nav\` attribute both match.`;
if (!buttons.length) {
buttons = `🤷♀️ We couldn't find any.\n
• Make sure your buttons have a \`data-snap-slider-goto\` attribute.`;
}
// We found it!
if (slider && slider.container) {
sliderContainer = slider.container;
}
// Log 'em.
return logger.section({
heading: `🗺 Navigation ${num}`,
description: nav,
groups: [
{
heading: '1. What slider is this nav targeting?',
items: [
{ heading: 'Slider ID', description: sliderID },
{ heading: 'Slider Element', description: sliderContainer },
{ heading: 'Slider Object', description: slider || "🤷♀️ We couldn't find any." },
],
},
{
heading: '2. What buttons are in this nav?',
items: [{ heading: 'Buttons', description: buttons }],
},
],
collapsed: true,
});
}
// 2c. Containers (aka sliders)
if (container) {
// Get details for the slider.
let sliderID = container.getAttribute('data-snap-slider');
const slider = window._SnapSliders[sliderID];
let navs = qsa(`[data-snap-slider-nav="${sliderID}"]`);
let buttons = slider ? slider.getButtons() : [];
// "We couldn't find anything."
sliderID = sliderID
? `"${sliderID}"`
: `🤷♀️ We couldn't find any.\n
• Include the slider ID you want in your \`data-snap-slider\` attribute.
◦ For example, \`data-snap-slider="example-slider"\`.`;
if (!navs.length) {
navs = `🤷♀️ We couldn't find any.\n
• Make sure the ID in your container's \`data-snap-slider\` attribute and the ID in your nav's \`data-snap-slider-nav\` attribute both match.`;
}
if (!buttons.length) {
buttons = `🤷♀️ We couldn't find any.\n
• Make sure your button is inside a \`data-snap-slider-nav\` element, or...
• Include the slider ID you want to target in your \`data-snap-slider-goto\` attribute.
◦ For example, \`data-snap-slider-goto="example-slider:prev"\`.`;
}
// Log 'em.
return logger.section({
heading: `🥨 Slider ${num}`,
description: container,
groups: [
{
heading: '1. What slider is this?',
items: [
{ heading: 'Slider ID', description: sliderID },
{ heading: 'Slider Object', description: slider || "🤷♀️ We couldn't find any." },
],
},
{
heading: '2. What navs target this slider?',
items: [{ heading: 'N