UNPKG

wj-elements

Version:

WebJET Elements is a modern set of user interface tools harnessing the power of web components designed to simplify web application development.

615 lines (614 loc) 22.9 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import WJElement from "./wje-element.js"; const styles = '/*\n[ Carousel ]\n*/\n\n:host {\n display: block;\n width: var(--wje-carousel-width, 100%);\n max-width: 100%;\n box-sizing: border-box;\n}\n\n.native-carousel {\n position: relative;\n width: 100%;\n height: var(--wje-carousel-height, 300px);\n scroll-behavior: smooth;\n box-sizing: border-box;\n}\n\n.slides-wrapper {\n position: relative;\n display: flex;\n align-items: stretch;\n width: 100%;\n height: var(--wje-carousel-height, 300px);\n box-sizing: border-box;\n}\n\n.carousel-slides {\n display: flex;\n flex: 1 1 auto;\n transition: transform 0.5s ease;\n align-items: stretch;\n overflow: auto;\n overscroll-behavior-x: contain;\n scrollbar-width: none;\n -ms-overflow-style: none;\n aspect-ratio: var(--wje-aspect-ratio, 4 / 3);\n scroll-snap-type: x mandatory;\n scroll-padding-inline: var(--wje-spacing-inline, 0);\n overflow-y: hidden;\n padding-inline: var(--wje-spacing-inline, 0);\n gap: var(--wje-carousel-gap, 0.5rem);\n width: 100%;\n height: 100%;\n min-width: 0;\n min-height: 0;\n box-sizing: border-box;\n}\n\n.carousel-slides::-webkit-scrollbar {\n display: none;\n}\n\n::slotted(wje-carousel-item) {\n flex: 0 0 var(--wje-carousel-item-basis, var(--wje-carousel-size));\n width: var(--wje-carousel-item-basis, var(--wje-carousel-size));\n min-width: 0;\n max-width: 100%;\n align-self: stretch;\n height: 100%;\n box-sizing: border-box;\n}\n\n/*NAVIGATION*/\n\n[name="prev"], [name="next"] {\n display: block;\n position: absolute;\n top: 50%;\n border: none;\n cursor: pointer;\n z-index: 2;\n}\n\n[name="prev"] {\n left: -1rem;\n transform: translate(-100%, -50%);\n}\n\n[name="next"] {\n right: -1rem;\n transform: translate(100%, -50%);\n}\n\n/*PAGINATION*/\n\n.pagination {\n position: relative;\n left: 50%;\n transform: translate(-50%, 0);\n display: flex;\n z-index: 2;\n justify-content: center;\n padding-block: 1rem;\n}\n.pagination-item {\n cursor: pointer;\n height: 15px;\n width: 15px;\n margin: 0 2px;\n background-color: var(--wje-color-contrast-4);\n display: inline-block;\n border-radius: 50%;\n}\n.pagination-item.active {\n background-color: var(--wje-color);\n}\n\n/*THUMBNAILS*/\n\n.thumbnails {\n display: flex;\n justify-content: center;\n align-items: center;\n overflow-x: auto;\n gap: 0.5rem;\n padding: 0 0.5rem;\n margin-top: 0.5rem;\n margin-bottom: 0.5rem;\n box-sizing: border-box;\n overflow-y: hidden;\n wje-thumbnail {\n --wje-thumbnail-width: 48px;\n --wje-thumbnail-height: 48px;\n --wje-thumbnail-border-radius: 0;\n cursor: pointer;\n border: 1px solid transparent;\n }\n .active {\n border: 1px solid var(--wje-color-primary-11);\n }\n}\n'; class Carousel extends WJElement { /** * Carousel constructor method. */ constructor() { super(); /** * Class name for the Carousel. * @type {string} */ __publicField(this, "className", "Carousel"); this.slidePerPage = 1; } /** * Active slide attribute. * @param value */ set activeSlide(value) { this.setAttribute("active-slide", value); } /** * Active slide attribute. * @returns {number|number} */ get activeSlide() { return +this.getAttribute("active-slide") || 0; } /** * Pagination attribute. * @returns {boolean} */ get pagination() { return this.hasAttribute("pagination"); } /** * Navigation attribute. * @returns {boolean} */ get navigation() { return this.hasAttribute("navigation"); } /** * Thumbnails attribute. * @returns {boolean} */ get thumbnails() { return this.hasAttribute("thumbnails"); } /** * Loop attribute. * @returns {boolean} */ get loop() { return this.hasAttribute("loop"); } /** * Continuous loop attribute. * @returns {boolean} */ get continuousLoop() { return this.hasAttribute("continuous-loop"); } /** * Getter for the CSS stylesheet. * @returns {*} */ static get cssStyleSheet() { return styles; } /** * Getter for the observed attributes. * @returns {string[]} */ static get observedAttributes() { return ["active-slide", "slide-per-page", "continuous-loop"]; } /** * Sets up the attributes for the Carousel. * @param name * @param old * @param newName */ attributeChangedCallback(name, old, newName) { if (name === "active-slide") { if (this.pagination) this.changePagination(); if (this.thumbnails) this.changeThumbnails(); } if (["slide-per-page", "continuous-loop"].includes(name) && old !== newName && this.slides) { this.syncSlideMetrics(); if (this.loop) { this.refresh(); return; } this.goToSlide(this.activeSlide, "auto"); } } /** * Sets up the attributes for the Carousel. */ setupAttributes() { this.isShadowRoot = "open"; this.syncAria(); } /** * Before draw method for the Carousel. */ beforeDraw() { this.syncSlideMetrics(); this.removeLoopClones(); this.cloneFirstAndLastItems(); } /** * Draw method for the Carousel. * @returns {DocumentFragment} */ draw() { let fragment = document.createDocumentFragment(); let native = document.createElement("div"); native.classList.add("native-carousel"); let wrapper = document.createElement("div"); wrapper.classList.add("slides-wrapper"); let slides = document.createElement("div"); slides.classList.add("carousel-slides"); let slot = document.createElement("slot"); let slotPrev = document.createElement("slot"); slotPrev.setAttribute("name", "prev"); let slotNext = document.createElement("slot"); slotNext.setAttribute("name", "next"); slides.append(slot); native.append(wrapper); if (this.navigation) { let existingPrev = this.querySelector('[slot="prev"]'); let existingNext = this.querySelector('[slot="next"]'); this.prevButton = existingPrev || this.createPreviousButton(); this.nextButton = existingNext || this.createNextButton(); if (this.prevButton && !this.prevButton.dataset.wjeCarouselNavBound) { this.prevButton.addEventListener("click", () => this.previousSlide()); this.prevButton.dataset.wjeCarouselNavBound = "true"; } if (this.nextButton && !this.nextButton.dataset.wjeCarouselNavBound) { this.nextButton.addEventListener("click", () => this.nextSlide()); this.nextButton.dataset.wjeCarouselNavBound = "true"; } if (!existingPrev) this.append(this.prevButton); if (!existingNext) this.append(this.nextButton); wrapper.append(slotPrev); wrapper.append(slotNext); } wrapper.append(slides); if (this.pagination) native.append(this.createPagination()); if (this.thumbnails) native.append(this.createThumbnails()); fragment.append(native); this.slides = slides; return fragment; } /** * After draw method for the Carousel. */ afterDraw() { this.setIntersectionObserver(); this.getSlidesWithClones().forEach((slide, i) => { this.intersectionObserver.observe(slide); }); this.syncSlideMetrics(); this.goToSlide(this.activeSlide, "auto"); requestAnimationFrame(() => requestAnimationFrame(() => this.syncActiveToSnapStart())); this.slides.addEventListener("scrollend", (e) => { this.syncActiveToSnapStart(); }); this.syncAria(); } /** * Sync `activeSlide` to the slide whose leading edge is closest to the snap start. */ syncActiveToSnapStart() { this.getSlides(); const withClones = this.getSlidesWithClones(); if (!withClones.length) return; const containerRect = this.slides.getBoundingClientRect(); const snapStartX = containerRect.left + this.getScrollPaddingInlineStart(); let best = null; let bestDist = Infinity; withClones.forEach((el) => { const r = el.getBoundingClientRect(); const dist = Math.abs(r.left - snapStartX); if (dist < bestDist) { bestDist = dist; best = el; } }); if (!best) return; const vIndex = withClones.indexOf(best); if (vIndex === -1) return; const logicalIndex = this.getLogicalIndexForVisual(vIndex); this.activeSlide = logicalIndex; this.setActiveVisualSlide(vIndex); const canonicalVisualIndex = this.getVisualIndexForLogical(logicalIndex); if (canonicalVisualIndex !== vIndex) { this.goToSlide(logicalIndex, "auto"); } } /** * Syncs computed CSS variables derived from `slide-per-page`. */ syncSlideMetrics() { this.slidePerPage = Math.max(parseInt(this.getAttribute("slide-per-page"), 10) || 1, 1); const visibleGapCount = Math.max(this.slidePerPage - 1, 0); const computedItemSize = `calc((100% - (${visibleGapCount} * var(--wje-carousel-gap, 0.5rem))) / ${this.slidePerPage})`; this.style.setProperty("--wje-carousel-slides-per-page", `${this.slidePerPage}`); this.style.setProperty("--wje-carousel-visible-gap-count", `${visibleGapCount}`); this.style.setProperty("--wje-carousel-size", computedItemSize); this.style.setProperty("--wje-carousel-item-basis", "var(--wje-carousel-size)"); } /** * Returns the inline scroll padding used by the snap area. * @returns {number} */ getScrollPaddingInlineStart() { if (!this.slides) return 0; const slideStyles = getComputedStyle(this.slides); return parseFloat(slideStyles.scrollPaddingInlineStart || slideStyles.scrollPaddingLeft || "0") || 0; } /** * Returns the interaction scroll behavior for UI controls. * Continuous multi-slide loops use instant snapping to avoid blank edge states * while the browser is still animating a previous smooth scroll. * @returns {string} */ getControlBehavior() { return this.continuousLoop && this.slidePerPage > 1 ? "auto" : "smooth"; } /** * Sets up the IntersectionObserver for the Carousel. */ setIntersectionObserver() { this.intersectionObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { this.entriesMap.set(entry.target, entry); }); }, { root: this.context.querySelector(".carousel-slides"), threshold: 0.5 } ); this.entriesMap = /* @__PURE__ */ new Map(); this.records = this.intersectionObserver.takeRecords(); this.records.forEach((entry) => { this.entriesMap.set(entry.target, entry); }); } /** * Goes to the slide. * @param index * @param behavior * @param next */ goToSlide(index, behavior = "smooth", next = true) { const slides = this.getSlides(); const withClones = this.getSlidesWithClones(); const maxIndex = this.getMaxVisibleStartIndex(slides.length); let logical; if (this.loop && slides.length > 0) { logical = this.normalizeLoopIndex(index, slides.length); } else { logical = Math.min(Math.max(index, 0), maxIndex); } this.activeSlide = logical; const vIndex = this.getVisualIndexForLogical(logical); const targetEl = withClones[vIndex]; if (!targetEl) return; this.setActiveVisualSlide(vIndex); this.scrollToVisualIndex(vIndex, behavior); if (this.navigation && !this.loop) { this.nextButton.removeAttribute("disabled"); this.prevButton.removeAttribute("disabled"); if (this.activeSlide === maxIndex) this.nextButton.setAttribute("disabled", ""); if (this.activeSlide === 0) this.prevButton.setAttribute("disabled", ""); } this.syncAria(); } /** * Sets the active class on the currently targeted visual slide and removes it elsewhere. * @param {number} vIndex */ setActiveVisualSlide(vIndex) { this.getSlidesWithClones().forEach((slide, index) => { slide.classList.toggle("active", index === vIndex); }); } /** * Syncs ARIA attributes on the carousel and slides. */ syncAria() { this.setAriaState({ role: "region", roledescription: "carousel" }); const slides = this.getSlides(); const total = slides.length; slides.forEach((slide, index) => { slide.setAttribute("role", "group"); slide.setAttribute("aria-roledescription", "slide"); slide.setAttribute("aria-label", `Slide ${index + 1} of ${total}`); slide.setAttribute("aria-hidden", slide.classList.contains("active") ? "false" : "true"); }); const clones = this.querySelectorAll(".clone"); clones.forEach((slide) => { slide.setAttribute("aria-hidden", "true"); }); } /** * Clones the first and last items. */ cloneFirstAndLastItems() { const items = this.getSlides(); if (items.length && this.loop) { const cloneCount = this.getLoopCloneCount(items.length); const firstOriginal = items[0]; items.slice(items.length - cloneCount).forEach((item) => { const clone = this.createLoopClone(item); this.insertBefore(clone, firstOriginal); }); items.slice(0, cloneCount).forEach((item) => { const clone = this.createLoopClone(item); this.append(clone); }); } } /** * Creates a sanitized loop clone that does not inherit transient render state * such as inline `visibility: hidden` from the source slide. * @param {HTMLElement} item * @returns {HTMLElement} */ createLoopClone(item) { var _a; const clone = item.cloneNode(true); clone.classList.add("clone"); clone.classList.remove("active"); clone.style.removeProperty("visibility"); if (!((_a = clone.getAttribute("style")) == null ? void 0 : _a.trim())) { clone.removeAttribute("style"); } return clone; } /** * Removes loop clones so they can be rebuilt for the current configuration. */ removeLoopClones() { this.querySelectorAll("wje-carousel-item.clone").forEach((clone) => clone.remove()); } /** * Returns how many slides should be cloned on each side when loop is enabled. * @param {number} totalSlides * @returns {number} */ getLoopCloneCount(totalSlides = this.getSlides().length) { if (!this.loop || !totalSlides) return 0; return this.continuousLoop ? Math.min(this.slidePerPage, totalSlides) : 1; } /** * Scrolls the carousel to a visual slide index. * @param {number} vIndex * @param {string} behavior */ scrollToVisualIndex(vIndex, behavior = "smooth") { const withClones = this.getSlidesWithClones(); const firstEl = withClones[0]; const targetEl = withClones[vIndex]; if (!firstEl || !targetEl || !this.slides) return; const firstRect = firstEl.getBoundingClientRect(); const targetRect = targetEl.getBoundingClientRect(); const contentOffsetLeft = targetRect.left - firstRect.left; const nextLeft = contentOffsetLeft - this.getScrollPaddingInlineStart(); const targetLeft = Math.max(nextLeft, 0); if (behavior === "smooth") { this.slides.scrollTo({ left: targetLeft, top: this.slides.scrollTop, behavior: "smooth" }); return; } if (this.snapRestoreFrame) { cancelAnimationFrame(this.snapRestoreFrame); } const inlineSnapType = this.slides.style.scrollSnapType; this.slides.style.scrollSnapType = "none"; this.slides.scrollTo({ left: targetLeft, top: this.slides.scrollTop, behavior: "auto" }); this.snapRestoreFrame = requestAnimationFrame(() => { this.slides.style.scrollSnapType = inlineSnapType; this.snapRestoreFrame = null; }); } /** * Goes to the next slide. */ removeActiveSlide() { this.getSlidesWithClones().forEach((slide, i) => { slide.classList.remove("active"); }); if (this.pagination) { this.context.querySelectorAll(".pagination-item").forEach((item) => { item.classList.remove("active"); }); } if (this.thumbnails) { this.context.querySelectorAll("wje-thumbnail").forEach((item) => { item.classList.remove("active"); }); } } /** * Goes to the next slide. */ changePagination() { if (this.pagination) { this.context.querySelectorAll(".pagination-item").forEach((item, i) => { item.classList.toggle("active", i === this.activeSlide); }); } } /** * Goes to the next slide. */ changeThumbnails() { if (this.thumbnails) { this.context.querySelectorAll("wje-thumbnail").forEach((item, i) => { item.classList.toggle("active", i === this.activeSlide); }); } } /** * Goes to the next slide. * @returns {Element} */ createNextButton() { const nextButton = document.createElement("wje-button"); nextButton.setAttribute("part", "next-button"); nextButton.setAttribute("circle", ""); nextButton.setAttribute("fill", "link"); nextButton.setAttribute("slot", "next"); nextButton.innerHTML = '<wje-icon name="chevron-right" size="large"></wje-icon>'; nextButton.classList.add("next"); return nextButton; } /** * Goes to the next slide. * @returns {Element} */ createPreviousButton() { const previousButton = document.createElement("wje-button"); previousButton.setAttribute("part", "previous-button"); previousButton.setAttribute("circle", ""); previousButton.setAttribute("fill", "link"); previousButton.setAttribute("slot", "prev"); previousButton.innerHTML = '<wje-icon name="chevron-left" size="large"></wje-icon>'; previousButton.classList.add("prev"); return previousButton; } /** * Goes to the next slide. * @returns {Element} */ createPagination() { const pagination = document.createElement("div"); pagination.setAttribute("part", "pagination"); pagination.classList.add("pagination"); this.getPaginationIndexes().forEach((i) => { const paginationItem = document.createElement("div"); paginationItem.classList.add("pagination-item"); paginationItem.addEventListener("click", (e) => { this.removeActiveSlide(); e.target.classList.add("active"); this.goToSlide(i, this.getControlBehavior()); }); pagination.append(paginationItem); }); return pagination; } /** * Goes to the next slide. * @returns {Element} */ createThumbnails() { const thumbnails = document.createElement("div"); thumbnails.classList.add("thumbnails"); const slides = this.getSlides(); slides.forEach((slide, i) => { const thumbnail = document.createElement("wje-thumbnail"); thumbnail.innerHTML = `<img src="${slide.querySelector("wje-img").getAttribute("src")}"></img>`; thumbnail.addEventListener("click", (e) => { this.removeActiveSlide(); e.target.closest("wje-thumbnail").classList.add("active"); this.goToSlide(i, this.getControlBehavior()); }); thumbnails.append(thumbnail); }); return thumbnails; } /** * Goes to the next slide. */ nextSlide() { this.goToSlide(this.activeSlide + 1, this.getControlBehavior()); } /** * Goes to the previous slide. */ previousSlide() { this.goToSlide(this.activeSlide - 1, this.getControlBehavior()); } /** * Goes to the slide. * @returns {Array} */ getSlides() { return Array.from(this.querySelectorAll("wje-carousel-item:not(.clone)")); } /** * Goes to the slide. * @returns {Array} */ getSlidesWithClones() { return Array.from(this.querySelectorAll("wje-carousel-item")); } /** Maps logical index to visual index, including leading clones when loop is enabled. */ getVisualIndexForLogical(index) { return this.loop ? index + this.getLoopCloneCount() : index; } /** Maps visual index to logical index, including edge clones when loop is enabled. */ getLogicalIndexForVisual(vIndex) { const slides = this.getSlides(); const maxIndex = this.getMaxVisibleStartIndex(slides.length); const cloneCount = this.getLoopCloneCount(slides.length); if (!this.loop) return Math.min(Math.max(vIndex, 0), maxIndex); if (this.continuousLoop) { if (vIndex < cloneCount) return slides.length - cloneCount + vIndex; if (vIndex >= cloneCount + slides.length) return vIndex - (cloneCount + slides.length); return vIndex - cloneCount; } if (vIndex < cloneCount) return maxIndex; if (vIndex >= cloneCount + slides.length) return 0; return Math.min(Math.max(vIndex - cloneCount, 0), maxIndex); } /** * Returns the maximum logical slide index that can still render a full viewport. * @param {number} totalSlides * @returns {number} */ getMaxVisibleStartIndex(totalSlides = this.getSlides().length) { const visibleSlides = Math.min(this.slidePerPage, totalSlides); return Math.max(totalSlides - visibleSlides, 0); } /** * Normalizes a logical index for the active loop mode. * @param {number} index * @param {number} totalSlides * @returns {number} */ normalizeLoopIndex(index, totalSlides = this.getSlides().length) { const logicalCount = this.getLoopLogicalCount(totalSlides); if (!logicalCount) return 0; return (index % logicalCount + logicalCount) % logicalCount; } /** * Returns how many logical positions are reachable for the current loop mode. * @param {number} totalSlides * @returns {number} */ getLoopLogicalCount(totalSlides = this.getSlides().length) { if (!totalSlides) return 0; return this.continuousLoop ? totalSlides : this.getMaxVisibleStartIndex(totalSlides) + 1; } /** * Returns the pagination indexes for the current carousel mode. * @returns {number[]} */ getPaginationIndexes() { return Array.from({ length: this.getLoopLogicalCount() }, (_, index) => index); } /** * Goes to the slide. * @returns {boolean} */ canGoNext() { const el = this.context.querySelector(".carousel-slides"); return el.scrollLeft < el.scrollWidth - el.clientWidth; } /** * Goes to the slide. * @returns {boolean} */ canGoPrevious() { const el = this.context.querySelector(".carousel-slides"); return el.scrollLeft > 0; } } WJElement.define("wje-carousel", Carousel); export { Carousel as default }; //# sourceMappingURL=wje-carousel.js.map