UNPKG

@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
"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;