@ebay/ebayui-core
Version:
Collection of core eBay components; considered to be the building blocks for all composite structures, pages & apps.
486 lines (485 loc) • 20.1 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const makeup_focusables_1 = __importDefault(require("makeup-focusables"));
// TODO check carousel
const event_utils_1 = require("../../common/event-utils");
const html_attributes_1 = require("../../common/html-attributes");
const on_scroll_debounced_1 = require("./utils/on-scroll-debounced");
const scroll_transition_1 = require("./utils/scroll-transition");
const dom_1 = require("../../common/dom");
// Used for carousel slide direction.
const LEFT = -1;
const RIGHT = 1;
class Carousel {
cleanupAsync() {
clearTimeout(this.autoplayTimeout);
cancelAnimationFrame(this.renderFrame);
cancelAnimationFrame(this.focusFrame);
if (this.cancelScrollTransition) {
this.cancelScrollTransition();
this.cancelScrollTransition = undefined;
}
}
emitUpdate() {
const { config, items } = this.state;
config.scrollTransitioning = false;
this.emit("move", {
visibleIndexes: items
.filter(({ fullyVisible }) => fullyVisible)
.map((item) => items.indexOf(item)),
});
}
handleScroll(scrollLeft) {
const { state } = this;
const { config, items, gap } = state;
let closest;
if (scrollLeft >= this.getMaxOffset(state) - gap) {
closest = items.length - 1;
}
else {
// Find the closest item using a binary search on each carousel slide.
const itemsPerSlide = state.itemsPerSlide || 1;
const totalItems = items.length;
let low = 0;
let high = Math.ceil(totalItems / itemsPerSlide) - 1;
while (high - low > 1) {
const mid = Math.floor((low + high) / 2);
if (scrollLeft > items[mid * itemsPerSlide].left) {
low = mid;
}
else {
high = mid;
}
}
const deltaLow = Math.abs(scrollLeft - items[low * itemsPerSlide].left);
const deltaHigh = Math.abs(scrollLeft - items[high * itemsPerSlide].left);
closest = this.normalizeIndex(state, (deltaLow > deltaHigh ? high : low) * itemsPerSlide);
}
if (state.index !== closest) {
this.skipScrolling = true;
config.preserveItems = true;
this.setState("index", closest);
this.emit("scroll", { index: closest });
}
}
getOffset(state) {
const { items, index } = state;
if (!items.length) {
return 0;
}
return Math.min(items[index].left, this.getMaxOffset(state)) || 0;
}
getMaxOffset({ items, slideWidth }) {
if (!items.length) {
return 0;
}
return Math.max(items[items.length - 1].right - slideWidth, 0) || 0;
}
getSlide({ index, itemsPerSlide }, i = index) {
if (!itemsPerSlide) {
return;
}
return Math.ceil(i / itemsPerSlide);
}
normalizeIndex({ items, itemsPerSlide }, index) {
if (index > 0) {
let result = index;
result %= items.length || 1; // Ensure index is within bounds.
result -= result % (itemsPerSlide || 1); // Round index to the nearest valid slide index.
result = Math.abs(result); // Ensure positive value.
return result;
}
return 0;
}
isAnimating(state) {
const { items, index } = state;
if (!items.length) {
return false;
}
const currentItem = items[index];
return (currentItem.left === undefined || currentItem.right === undefined);
}
getNextIndex(state, delta) {
const { index, items, slideWidth, itemsPerSlide } = state;
let i = index;
let item;
// If going backward from 0, we go to the end.
if (delta === LEFT && i === 0) {
i = items.length - 1;
}
else {
// Find the index of the next item that is not fully in view.
do {
item = items[(i += delta)];
} while (item && item.fullyVisible);
if (delta === LEFT && !itemsPerSlide) {
// If going left without items per slide, go as far left as possible while keeping this item fully in view.
const targetOffset = item.right - slideWidth;
do {
item = items[--i];
} while (item && item.left >= targetOffset);
i += 1;
}
}
return this.normalizeIndex(state, i);
}
getTemplateData(state) {
const { config, autoplayInterval, items, itemsPerSlide, slideWidth, gap, } = state;
const hasOverride = config.offsetOverride !== undefined;
const isSingleSlide = items.length <= itemsPerSlide;
state.index = this.normalizeIndex(state, state.index);
const offset = this.getOffset(state);
const prevControlDisabled = isSingleSlide || (!autoplayInterval && offset === 0);
const nextControlDisabled = isSingleSlide ||
(!autoplayInterval && offset === this.getMaxOffset(state));
// If left/right is undefined, the carousel is moving at that moment. We should keep the old disabled state
const bothControlsDisabled = this.isAnimating(state)
? state.bothControlsDisabled
: prevControlDisabled && nextControlDisabled;
let slide, itemWidth, totalSlides;
if (itemsPerSlide) {
const itemsInSlide = itemsPerSlide + state.peek;
slide = this.getSlide(state);
itemWidth = `calc(${100 / itemsInSlide}% - ${((itemsInSlide - 1) * gap) / itemsInSlide}px)`;
totalSlides = this.getSlide(state, items.length);
}
items.forEach((item, i) => {
const { style, transform } = item;
const marginRight = i !== items.length - 1 && `${gap}px`;
// Account for users providing a style string or object for each item.
if (typeof style === "string") {
item.style = `${style};flex-basis:${itemWidth};margin-right:${marginRight};`;
if (transform)
item.style += `transform:${transform}`;
}
else {
item.style = Object.assign({}, style, {
width: itemWidth,
"margin-right": marginRight,
transform,
});
}
item.fullyVisible =
item.left === undefined ||
(item.left - offset >= -0.01 &&
item.right - offset <= slideWidth + 0.01);
});
const data = Object.assign({}, state, {
items,
slide,
offset: hasOverride ? config.offsetOverride : offset,
disableTransition: hasOverride,
totalSlides,
prevControlDisabled,
nextControlDisabled,
bothControlsDisabled,
});
return data;
}
move(delta) {
const { state } = this;
const { index, items, itemsPerSlide, autoplayInterval, slideWidth, gap, peek, config, } = state;
const nextIndex = this.getNextIndex(state, delta);
let offsetOverride;
config.preserveItems = true;
this.isMoving = true;
this.skipScrolling = false;
// When we are in autoplay mode we overshoot the desired index to land on a clone
// of one of the ends. Then after the transition is over we update to the proper position.
if (autoplayInterval) {
if (delta === RIGHT && nextIndex < index) {
// Transitions to one slide before the beginning.
offsetOverride = -slideWidth - gap;
// Move the items in the last slide to be before the first slide.
for (let i = Math.ceil(itemsPerSlide + peek); i--;) {
const item = items[items.length - i - 1];
item.transform = `translateX(${(this.getMaxOffset(state) + slideWidth + gap) * -1}px)`;
}
}
else if (delta === LEFT && nextIndex > index) {
// Transitions one slide past the end.
offsetOverride = this.getMaxOffset(state) + slideWidth + gap;
// Moves the items in the first slide to be after the last slide.
for (let i = Math.ceil(itemsPerSlide + peek); i--;) {
const item = items[i];
item.transform = `translateX(${this.getMaxOffset(state) + slideWidth + gap}px)`;
}
}
config.offsetOverride = offsetOverride;
}
this.setState("index", nextIndex);
this.once("move", () => {
this.isMoving = false;
if (offsetOverride !== undefined) {
// If we are in autoplay mode and went outside of the normal offset
// We make sure to restore all of the items that got moved around.
items.forEach((item) => {
item.transform = undefined;
});
}
});
return nextIndex;
}
handleMove(direction, originalEvent) {
if (this.isMoving) {
return;
}
const { state } = this;
const nextIndex = this.move(direction);
const slide = this.getSlide(state, nextIndex);
this.emit("slide", { slide: slide + 1, originalEvent });
this.emit(`${direction === 1 ? "next" : "previous"}`, {
originalEvent,
});
}
handleStartInteraction() {
this.setState("interacting", true);
}
handleEndInteraction() {
// In case the user moves the cursor out of the carousel before the transition is over.
// We need to make sure the carousel does not rerender in the middle of the transition.
clearTimeout(this.interactionEndTimeout);
if (!this.isMoving) {
this.setState("interacting", false);
}
else if (this.state.interacting) {
this.interactionEndTimeout = setTimeout(() => {
this.handleEndInteraction();
}, 100);
}
}
togglePlay(originalEvent) {
const { state: { config, paused }, } = this;
config.preserveItems = true;
this.setState("paused", !paused);
if (paused && !this.isMoving) {
this.move(RIGHT);
}
this.emit(`${paused ? "play" : "pause"}`, { originalEvent });
}
onInput(input) {
var _a;
const gap = parseInt(input.gap, 10);
const state = {
htmlAttributes: (0, html_attributes_1.processHtmlAttributes)(input, [
"class",
"style",
"index",
"type",
"slide",
"gap",
"autoplay",
"paused",
"itemsPerSlide",
"a11yPreviousText",
"a11yNextText",
"a11yPlayText",
"a11yPauseText",
"item",
"hiddenScrollbar",
]),
classes: [
"carousel",
input.hiddenScrollbar && "carousel--hidden-scrollbar",
input.class,
],
style: input.style,
config: {}, // A place to store values that should not trigger an update by themselves.
gap: isNaN(gap) ? 16 : gap,
index: parseInt(input.index, 10) || 0,
itemsPerSlide: parseFloat(input.itemsPerSlide) || 0,
a11yPreviousText: input.a11yPreviousText || "Previous Slide",
a11yNextText: input.a11yNextText || "Next Slide",
a11yPauseText: input.a11yPauseText || "Pause",
a11yPlayText: input.a11yPlayText || "Play",
items: [],
slideWidth: 0,
autoplayInterval: 0,
paused: false,
peek: 0,
interacting: false,
bothControlsDisabled: false,
};
const itemSkippedAttributes = ["class", "style", "key"];
const { itemsPerSlide } = state;
if (itemsPerSlide) {
state.peek = itemsPerSlide % 1;
state.itemsPerSlide = itemsPerSlide - state.peek;
state.classes.push("carousel--slides");
if (!state.peek && !input.autoplay && !input.noPeek) {
state.peek = 0.1;
}
if (state.peek) {
state.classes.push("carousel--peek");
}
// Only allow autoplay option for discrete carousels.
if (input.autoplay) {
const isSingleSlide = ((_a = input.item) === null || _a === void 0 ? void 0 : _a.length) <= itemsPerSlide;
state.autoplayInterval =
parseInt(input.autoplay, 10) || 4000;
state.classes.push("carousel__autoplay");
state.paused = !!(isSingleSlide || input.paused); // Force paused state if not enough slides provided;
state.interacting = false;
}
}
state.items = (input.item || []).map((item, i) => {
const isStartOfSlide = state.itemsPerSlide
? i % state.itemsPerSlide === 0
: true;
return {
htmlAttributes: (0, html_attributes_1.processHtmlAttributes)(item, itemSkippedAttributes),
class: isStartOfSlide
? ["carousel__snap-point", item.class]
: item.class,
key: item.key || i.toString(),
style: item.style,
renderBody: item.renderBody,
};
});
this.skipScrolling = this.state && this.state.index === state.index;
this.state = state;
}
onRender() {
if (typeof window !== "undefined") {
this.cleanupAsync();
}
}
onMount() {
const { config } = this.state;
this.listEl = this.getEl("list");
this.nextEl = this.getEl("next");
this.containerEl = this.getEl("container");
this.subscribeTo(event_utils_1.resizeUtil).on("resize", () => {
this.cleanupAsync();
this.onRenderLegacy();
});
this.skipScrolling = false;
// If user had reduced motion turned on in OS settings, pause autoplay.
if (dom_1.useReducedMotion) {
this.state.paused = true;
}
if (isNativeScrolling(this.listEl)) {
config.nativeScrolling = true;
this.once("destroy", (0, on_scroll_debounced_1.onScrollDebounced)(this.listEl, () => {
if (!config.scrollTransitioning) {
this.handleScroll(this.listEl.scrollLeft);
}
}));
}
else {
this.subscribeTo(this.listEl).on("transitionend", ({ target }) => {
if (target === this.listEl) {
this.emitUpdate();
}
});
}
this.onRenderLegacy();
document.fonts.ready.then(() => {
this.cleanupAsync();
this.onRenderLegacy();
});
}
onUpdate() {
this.onRenderLegacy();
}
onDestroy() {
this.cleanupAsync();
}
onRenderLegacy() {
const { containerEl, listEl, state } = this;
const { config, items, autoplayInterval, paused, interacting } = state;
// Do nothing for empty carousels.
if (!items.length) {
return;
}
// Force a rerender to start the offset override animation.
if (config.offsetOverride) {
config.offsetOverride = undefined;
this.renderFrame = requestAnimationFrame(() => this.setStateDirty(undefined));
return;
}
// Track if we are on a normal render or a render caused by recalculating.
if (config.preserveItems) {
config.preserveItems = false;
// Ensure only visible items within the carousel are focusable.
// We don't have access to these items in the template so me must update manually.
this.focusFrame = requestAnimationFrame(() => {
forEls(listEl, (itemEl) => {
(0, makeup_focusables_1.default)(itemEl).forEach(itemEl.getAttribute("aria-hidden") !== "true"
? // Default the child tabindex to data-carousel-tabindex if it exists, or remove it
(child) => {
var _a;
return child.hasAttribute("data-carousel-tabindex")
? child.setAttribute("tabindex", (_a = child.getAttribute("data-carousel-tabindex")) !== null && _a !== void 0 ? _a : "-1")
: child.removeAttribute("tabindex");
}
: (child) => child.setAttribute("tabindex", "-1"));
});
});
if (config.nativeScrolling) {
if (this.skipScrolling) {
this.emitUpdate();
}
else {
const offset = this.getOffset(state);
if (offset !== listEl.scrollLeft) {
// Animate to the new scrolling position and emit update events afterward.
config.scrollTransitioning = true;
this.cancelScrollTransition = (0, scroll_transition_1.scrollTransition)(listEl, offset, this.emitUpdate.bind(this));
}
else if (this.isMoving) {
// Animate to the new scrolling position and emit update events afterward.
config.scrollTransitioning = true;
this.cancelScrollTransition = (0, scroll_transition_1.scrollTransition)(listEl, this.getOffset(state), this.emitUpdate.bind(this));
}
}
}
if (autoplayInterval && !paused && !interacting) {
const moveRight = this.move.bind(this, RIGHT);
this.autoplayTimeout = setTimeout(() => {
if (this.isMoving) {
return this.once("move", moveRight);
}
moveRight();
}, autoplayInterval);
}
return;
}
// Otherwise recalculates the items / slide sizes.
this.renderFrame = requestAnimationFrame(() => {
const { width: containerWidth } = containerEl.getBoundingClientRect();
const { left: currentLeft } = listEl.firstElementChild.getBoundingClientRect();
this.setStateDirty("slideWidth", containerWidth);
config.preserveItems = true;
config.nativeScrolling = isNativeScrolling(listEl);
// Update item positions in the dom.
forEls(listEl, (itemEl, i) => {
const item = items[i];
const { left, right } = itemEl.getBoundingClientRect();
item.left = left - currentLeft;
item.right = right - currentLeft;
});
});
}
}
/**
* Checks if an element is using native scrolling
*/
function isNativeScrolling(el) {
return getComputedStyle(el).overflowX !== "visible";
}
/**
* Calls a function on each element within a parent element
*/
function forEls(parent, fn) {
let i = 0;
let child = parent.firstElementChild;
while (child) {
fn(child, i++);
child = child.nextElementSibling;
}
}
module.exports = Carousel;