UNPKG

@fancyapps/ui

Version:

JavaScript UI Component Library

1,606 lines (1,240 loc) 41.5 kB
import { extend } from "../shared/utils/extend.js"; import { canUseDOM } from "../shared/utils/canUseDOM.js"; import { FOCUSABLE_ELEMENTS, setFocusOn } from "../shared/utils/setFocusOn.js"; import { Base } from "../shared/Base/Base.js"; import { Carousel } from "../Carousel/Carousel.js"; import { Plugins } from "./plugins/index.js"; // Default language import en from "./l10n/en.js"; // Default settings const defaults = { // Index of active slide on the start startIndex: 0, // Number of slides to preload before and after active slide preload: 1, // Should navigation be infinite infinite: true, // Class name to be applied to the content to reveal it showClass: "fancybox-zoomInUp", // "fancybox-fadeIn" | "fancybox-zoomInUp" | false // Class name to be applied to the content to hide it hideClass: "fancybox-fadeOut", // "fancybox-fadeOut" | "fancybox-zoomOutDown" | false // Should backdrop and UI elements fade in/out on start/close animated: true, // If browser scrollbar should be hidden hideScrollbar: true, // Element containing main structure parentEl: null, // Custom class name or multiple space-separated class names for the container mainClass: null, // Set focus on first focusable element after displaying content autoFocus: true, // Trap focus inside Fancybox trapFocus: true, // Set focus back to trigger element after closing Fancybox placeFocusBack: true, // Action to take when the user clicks on the backdrop click: "close", // "close" | "next" | null // Position of the close button - over the content or at top right corner of viewport closeButton: "inside", // "inside" | "outside" // Allow user to drag content up/down to close instance dragToClose: true, // Enable keyboard navigation keyboard: { Escape: "close", Delete: "close", Backspace: "close", PageUp: "next", PageDown: "prev", ArrowUp: "next", ArrowDown: "prev", ArrowRight: "next", ArrowLeft: "prev", }, // HTML templates for various elements template: { // Close button icon closeButton: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" tabindex="-1"><path d="M20 20L4 4m16 0L4 20"/></svg>', // Loading indicator icon spinner: '<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="25 25 50 50" tabindex="-1"><circle cx="50" cy="50" r="20"/></svg>', // Main container element main: null, }, /* Note: If the `template.main` option is not provided, the structure is generated as follows by default: <div class="fancybox__container" role="dialog" aria-modal="true" aria-hidden="true" aria-label="{{MODAL}}" tabindex="-1"> <div class="fancybox__backdrop"></div> <div class="fancybox__carousel"></div> </div> */ // Localization of strings l10n: en, }; // Object that contains all active instances of Fancybox const instances = new Map(); // Number of Fancybox instances created, it is used to generate new instance "id" let called = 0; class Fancybox extends Base { /** * Fancybox constructor * @constructs Fancybox * @param {Object} [options] - Options for Fancybox */ constructor(items, options = {}) { // Quick hack to fix variable naming collision items = items.map((item) => { if (item.width) item._width = item.width; if (item.height) item._height = item.height; return item; }); super(extend(true, {}, defaults, options)); this.bindHandlers(); this.state = "init"; this.setItems(items); this.attachPlugins(Fancybox.Plugins); // "init" event marks the start of initialization and is available to plugins this.trigger("init"); if (this.option("hideScrollbar") === true) { this.hideScrollbar(); } this.initLayout(); this.initCarousel(); this.attachEvents(); instances.set(this.id, this); // "prepare" event will trigger the creation of additional layout elements, such as thumbnails and toolbar this.trigger("prepare"); this.state = "ready"; // "ready" event will trigger the content to load this.trigger("ready"); // Reveal container this.$container.setAttribute("aria-hidden", "false"); // Set focus on the first focusable element inside this instance if (this.option("trapFocus")) { this.focus(); } } /** * Override `option` method to get value from the current slide * @param {String} name option name * @param {...any} rest optional extra parameters * @returns {any} */ option(name, ...rest) { const slide = this.getSlide(); let value = slide ? slide[name] : undefined; if (value !== undefined) { if (typeof value === "function") { value = value.call(this, this, ...rest); } return value; } return super.option(name, ...rest); } /** * Bind event handlers for referencability */ bindHandlers() { for (const methodName of [ "onMousedown", "onKeydown", "onClick", "onFocus", "onCreateSlide", "onSettle", "onTouchMove", "onTouchEnd", "onTransform", ]) { this[methodName] = this[methodName].bind(this); } } /** * Set up a functions that will be called whenever the specified event is delivered */ attachEvents() { document.addEventListener("mousedown", this.onMousedown); document.addEventListener("keydown", this.onKeydown, true); // Trap keyboard focus inside of the modal if (this.option("trapFocus")) { document.addEventListener("focus", this.onFocus, true); } this.$container.addEventListener("click", this.onClick); } /** * Removes previously registered event listeners */ detachEvents() { document.removeEventListener("mousedown", this.onMousedown); document.removeEventListener("keydown", this.onKeydown, true); document.removeEventListener("focus", this.onFocus, true); this.$container.removeEventListener("click", this.onClick); } /** * Initialize layout; create main container, backdrop nd layout for main carousel */ initLayout() { this.$root = this.option("parentEl") || document.body; // Container let mainTemplate = this.option("template.main"); if (mainTemplate) { this.$root.insertAdjacentHTML("beforeend", this.localize(mainTemplate)); this.$container = this.$root.querySelector(".fancybox__container"); } if (!this.$container) { this.$container = document.createElement("div"); this.$root.appendChild(this.$container); } // Normally we would not need this, but Safari does not support `preventScroll:false` option for `focus` method // and that causes layout issues this.$container.onscroll = () => { this.$container.scrollLeft = 0; return false; }; Object.entries({ class: "fancybox__container", role: "dialog", tabIndex: "-1", "aria-modal": "true", "aria-hidden": "true", "aria-label": this.localize("{{MODAL}}"), }).forEach((args) => this.$container.setAttribute(...args)); if (this.option("animated")) { this.$container.classList.add("is-animated"); } // Backdrop this.$backdrop = this.$container.querySelector(".fancybox__backdrop"); if (!this.$backdrop) { this.$backdrop = document.createElement("div"); this.$backdrop.classList.add("fancybox__backdrop"); this.$container.appendChild(this.$backdrop); } // Carousel this.$carousel = this.$container.querySelector(".fancybox__carousel"); if (!this.$carousel) { this.$carousel = document.createElement("div"); this.$carousel.classList.add("fancybox__carousel"); this.$container.appendChild(this.$carousel); } // Make instance reference accessible this.$container.Fancybox = this; // Make sure the container has an ID this.id = this.$container.getAttribute("id"); if (!this.id) { this.id = this.options.id || ++called; this.$container.setAttribute("id", "fancybox-" + this.id); } // Add custom class name to main element const mainClass = this.option("mainClass"); if (mainClass) { this.$container.classList.add(...mainClass.split(" ")); } // Add class name for <html> element document.documentElement.classList.add("with-fancybox"); this.trigger("initLayout"); return this; } /** * Prepares slides for the corousel * @returns {Array} Slides */ setItems(items) { const slides = []; for (const slide of items) { const $trigger = slide.$trigger; if ($trigger) { const dataset = $trigger.dataset || {}; slide.src = dataset.src || $trigger.getAttribute("href") || slide.src; slide.type = dataset.type || slide.type; // Support items without `src`, e.g., when `data-fancybox` attribute added directly to `<img>` element if (!slide.src && $trigger instanceof HTMLImageElement) { slide.src = $trigger.currentSrc || slide.$trigger.src; } } // Check for thumbnail element let $thumb = slide.$thumb; if (!$thumb) { let origTarget = slide.$trigger && slide.$trigger.origTarget; if (origTarget) { if (origTarget instanceof HTMLImageElement) { $thumb = origTarget; } else { $thumb = origTarget.querySelector("img:not([aria-hidden])"); } } if (!$thumb && slide.$trigger) { $thumb = slide.$trigger instanceof HTMLImageElement ? slide.$trigger : slide.$trigger.querySelector("img:not([aria-hidden])"); } } slide.$thumb = $thumb || null; // Get thumbnail image source let thumb = slide.thumb; if (!thumb && $thumb) { thumb = $thumb.currentSrc || $thumb.src; if (!thumb && $thumb.dataset) { thumb = $thumb.dataset.lazySrc || $thumb.dataset.src; } } // Assume we have image, then use it as thumbnail if (!thumb && slide.type === "image") { thumb = slide.src; } slide.thumb = thumb || null; // Add empty caption to make things simpler slide.caption = slide.caption || ""; slides.push(slide); } this.items = slides; } /** * Initialize main Carousel that will be used to display the content * @param {Array} slides */ initCarousel() { this.Carousel = new Carousel( this.$carousel, extend( true, {}, { prefix: "", classNames: { viewport: "fancybox__viewport", track: "fancybox__track", slide: "fancybox__slide", }, textSelection: true, preload: this.option("preload"), friction: 0.88, slides: this.items, initialPage: this.options.startIndex, slidesPerPage: 1, infiniteX: this.option("infinite"), infiniteY: true, l10n: this.option("l10n"), Dots: false, Navigation: { classNames: { main: "fancybox__nav", button: "carousel__button", next: "is-next", prev: "is-prev", }, }, Panzoom: { textSelection: true, panOnlyZoomed: () => { return ( this.Carousel && this.Carousel.pages && this.Carousel.pages.length < 2 && !this.option("dragToClose") ); }, lockAxis: () => { if (this.Carousel) { let rez = "x"; if (this.option("dragToClose")) { rez += "y"; } return rez; } }, }, on: { "*": (name, ...details) => this.trigger(`Carousel.${name}`, ...details), init: (carousel) => (this.Carousel = carousel), createSlide: this.onCreateSlide, settle: this.onSettle, }, }, this.option("Carousel") ) ); if (this.option("dragToClose")) { this.Carousel.Panzoom.on({ // Stop further touch event handling if content is scaled touchMove: this.onTouchMove, // Update backdrop opacity depending on vertical distance afterTransform: this.onTransform, // Close instance if drag distance exceeds limit touchEnd: this.onTouchEnd, }); } this.trigger("initCarousel"); return this; } /** * Process `createSlide` event to create caption element inside new slide */ onCreateSlide(carousel, slide) { let caption = slide.caption || ""; if (typeof this.options.caption === "function") { caption = this.options.caption.call(this, this, this.Carousel, slide); } if (typeof caption === "string" && caption.length) { const $caption = document.createElement("div"); const id = `fancybox__caption_${this.id}_${slide.index}`; $caption.className = "fancybox__caption"; $caption.innerHTML = caption; $caption.setAttribute("id", id); slide.$caption = slide.$el.appendChild($caption); slide.$el.classList.add("has-caption"); slide.$el.setAttribute("aria-labelledby", id); } } /** * Handle Carousel `settle` event */ onSettle() { if (this.option("autoFocus")) { this.focus(); } } /** * Handle focus event * @param {Event} event - Focus event */ onFocus(event) { this.focus(event); } /** * Handle click event on the container * @param {Event} event - Click event */ onClick(event) { if (event.defaultPrevented) { return; } let eventTarget = event.composedPath()[0]; if (eventTarget.matches("[data-fancybox-close]")) { event.preventDefault(); Fancybox.close(false, event); return; } if (eventTarget.matches("[data-fancybox-next]")) { event.preventDefault(); Fancybox.next(); return; } if (eventTarget.matches("[data-fancybox-prev]")) { event.preventDefault(); Fancybox.prev(); return; } if (!eventTarget.matches(FOCUSABLE_ELEMENTS)) { document.activeElement.blur(); } // Skip if clicked inside content area if (eventTarget.closest(".fancybox__content")) { return; } // Skip if text is selected if (getSelection().toString().length) { return; } if (this.trigger("click", event) === false) { return; } const action = this.option("click"); switch (action) { case "close": this.close(); break; case "next": this.next(); break; } } /** * Handle panzoom `touchMove` event; Disable dragging if content of current slide is scaled */ onTouchMove() { const panzoom = this.getSlide().Panzoom; return panzoom && panzoom.content.scale !== 1 ? false : true; } /** * Handle panzoom `touchEnd` event; close when quick flick up/down is detected * @param {Object} panzoom - Panzoom instance */ onTouchEnd(panzoom) { const distanceY = panzoom.dragOffset.y; if (Math.abs(distanceY) >= 150 || (Math.abs(distanceY) >= 35 && panzoom.dragOffset.time < 350)) { if (this.option("hideClass")) { this.getSlide().hideClass = `fancybox-throwOut${panzoom.content.y < 0 ? "Up" : "Down"}`; } this.close(); } else if (panzoom.lockAxis === "y") { panzoom.panTo({ y: 0 }); } } /** * Handle `afterTransform` event; change backdrop opacity based on current y position of panzoom * @param {Object} panzoom - Panzoom instance */ onTransform(panzoom) { const $backdrop = this.$backdrop; if ($backdrop) { const yPos = Math.abs(panzoom.content.y); const opacity = yPos < 1 ? "" : Math.max(0.33, Math.min(1, 1 - (yPos / panzoom.content.fitHeight) * 1.5)); this.$container.style.setProperty("--fancybox-ts", opacity ? "0s" : ""); this.$container.style.setProperty("--fancybox-opacity", opacity); } } /** * Handle `mousedown` event to mark that the mouse is in use */ onMousedown() { if (this.state === "ready") { document.body.classList.add("is-using-mouse"); } } /** * Handle `keydown` event; trap focus * @param {Event} event Keydown event */ onKeydown(event) { if (Fancybox.getInstance().id !== this.id) { return; } document.body.classList.remove("is-using-mouse"); const key = event.key; const keyboard = this.option("keyboard"); if (!keyboard || event.ctrlKey || event.altKey || event.shiftKey) { return; } const target = event.composedPath()[0]; const classList = document.activeElement && document.activeElement.classList; const isUIElement = classList && classList.contains("carousel__button"); // Allow to close using Escape button if (key !== "Escape" && !isUIElement) { let ignoreElements = event.target.isContentEditable || ["BUTTON", "TEXTAREA", "OPTION", "INPUT", "SELECT", "VIDEO"].indexOf(target.nodeName) !== -1; if (ignoreElements) { return; } } if (this.trigger("keydown", key, event) === false) { return; } const action = keyboard[key]; if (typeof this[action] === "function") { this[action](); } } /** * Get the active slide. This will be the first slide from the current page of the main carousel. */ getSlide() { const carousel = this.Carousel; if (!carousel) return null; const page = carousel.page === null ? carousel.option("initialPage") : carousel.page; const pages = carousel.pages || []; if (pages.length && pages[page]) { return pages[page].slides[0]; } return null; } /** * Place focus on the first focusable element inside current slide * @param {Event} [event] - Focus event */ focus(event) { if (Fancybox.ignoreFocusChange) { return; } if (["init", "closing", "customClosing", "destroy"].indexOf(this.state) > -1) { return; } const $container = this.$container; const currentSlide = this.getSlide(); const $currentSlide = currentSlide.state === "done" ? currentSlide.$el : null; // Skip if the DOM element that is currently in focus is already inside the current slide if ($currentSlide && $currentSlide.contains(document.activeElement)) { return; } if (event) { event.preventDefault(); } Fancybox.ignoreFocusChange = true; const allFocusableElems = Array.from($container.querySelectorAll(FOCUSABLE_ELEMENTS)); let enabledElems = []; let $firstEl; for (let node of allFocusableElems) { // Enable element if it's visible and // is inside the current slide or is outside main carousel (for example, inside the toolbar) const isNodeVisible = node.offsetParent; const isNodeInsideCurrentSlide = $currentSlide && $currentSlide.contains(node); const isNodeOutsideCarousel = !this.Carousel.$viewport.contains(node); if (isNodeVisible && (isNodeInsideCurrentSlide || isNodeOutsideCarousel)) { enabledElems.push(node); if (node.dataset.origTabindex !== undefined) { node.tabIndex = node.dataset.origTabindex; node.removeAttribute("data-orig-tabindex"); } if ( node.hasAttribute("autoFocus") || (!$firstEl && isNodeInsideCurrentSlide && !node.classList.contains("carousel__button")) ) { $firstEl = node; } } else { // Element is either hidden or is inside preloaded slide (e.g., not inside current slide, but next/prev) node.dataset.origTabindex = node.dataset.origTabindex === undefined ? node.getAttribute("tabindex") : node.dataset.origTabindex; node.tabIndex = -1; } } if (!event) { if (this.option("autoFocus") && $firstEl) { setFocusOn($firstEl); } else if (enabledElems.indexOf(document.activeElement) < 0) { setFocusOn($container); } } else { if (enabledElems.indexOf(event.target) > -1) { this.lastFocus = event.target; } else { if (this.lastFocus === $container) { setFocusOn(enabledElems[enabledElems.length - 1]); } else { setFocusOn($container); } } } this.lastFocus = document.activeElement; Fancybox.ignoreFocusChange = false; } /** * Hide vertical page scrollbar and adjust right padding value of `body` element to prevent content from shifting * (otherwise the `body` element may become wider and the content may expand horizontally). */ hideScrollbar() { if (!canUseDOM) { return; } const scrollbarWidth = window.innerWidth - document.documentElement.getBoundingClientRect().width; const id = "fancybox-style-noscroll"; let $style = document.getElementById(id); if ($style) { return; } if (scrollbarWidth > 0) { $style = document.createElement("style"); $style.id = id; $style.type = "text/css"; $style.innerHTML = `.compensate-for-scrollbar {padding-right: ${scrollbarWidth}px;}`; document.getElementsByTagName("head")[0].appendChild($style); document.body.classList.add("compensate-for-scrollbar"); } } /** * Stop hiding vertical page scrollbar */ revealScrollbar() { document.body.classList.remove("compensate-for-scrollbar"); const el = document.getElementById("fancybox-style-noscroll"); if (el) { el.remove(); } } /** * Remove content for given slide * @param {Object} slide - Carousel slide */ clearContent(slide) { // * Clear previously added content and class name this.Carousel.trigger("removeSlide", slide); if (slide.$content) { slide.$content.remove(); slide.$content = null; } if (slide.$closeButton) { slide.$closeButton.remove(); slide.$closeButton = null; } if (slide._className) { slide.$el.classList.remove(slide._className); } } /** * Set new content for given slide * @param {Object} slide - Carousel slide * @param {HTMLElement|String} html - HTML element or string containing HTML code * @param {Object} [opts] - Options */ setContent(slide, html, opts = {}) { let $content; const $el = slide.$el; if (html instanceof HTMLElement) { if (["img", "iframe", "video", "audio"].indexOf(html.nodeName.toLowerCase()) > -1) { $content = document.createElement("div"); $content.appendChild(html); } else { $content = html; } } else { const $fragment = document.createRange().createContextualFragment(html); $content = document.createElement("div"); $content.appendChild($fragment); } if (slide.filter && !slide.error) { $content = $content.querySelector(slide.filter); } if (!($content instanceof Element)) { this.setError(slide, "{{ELEMENT_NOT_FOUND}}"); return; } // * Add class name indicating content type, for example `has-image` slide._className = `has-${opts.suffix || slide.type || "unknown"}`; $el.classList.add(slide._className); // * Set content $content.classList.add("fancybox__content"); // Make sure that content is not hidden and will be visible if ($content.style.display === "none" || getComputedStyle($content).getPropertyValue("display") === "none") { $content.style.display = slide.display || this.option("defaultDisplay") || "flex"; } if (slide.id) { $content.setAttribute("id", slide.id); } slide.$content = $content; $el.prepend($content); this.manageCloseButton(slide); if (slide.state !== "loading") { this.revealContent(slide); } return $content; } /** * Create close button if needed * @param {Object} slide */ manageCloseButton(slide) { const position = slide.closeButton === undefined ? this.option("closeButton") : slide.closeButton; if (!position || (position === "top" && this.$closeButton)) { return; } const $btn = document.createElement("button"); $btn.classList.add("carousel__button", "is-close"); $btn.setAttribute("title", this.options.l10n.CLOSE); $btn.innerHTML = this.option("template.closeButton"); $btn.addEventListener("click", (e) => this.close(e)); if (position === "inside") { // Remove existing one to avoid scope issues if (slide.$closeButton) { slide.$closeButton.remove(); } slide.$closeButton = slide.$content.appendChild($btn); } else { this.$closeButton = this.$container.insertBefore($btn, this.$container.firstChild); } } /** * Make content visible for given slide and optionally start CSS animation * @param {Object} slide - Carousel slide */ revealContent(slide) { this.trigger("reveal", slide); slide.$content.style.visibility = ""; // Add CSS class name that reveals content (default animation is "fadeIn") let showClass = false; if ( !( slide.error || slide.state === "loading" || this.Carousel.prevPage !== null || slide.index !== this.options.startIndex ) ) { showClass = slide.showClass === undefined ? this.option("showClass") : slide.showClass; } if (!showClass) { this.done(slide); return; } slide.state = "animating"; this.animateCSS(slide.$content, showClass, () => { this.done(slide); }); } /** * Add class name to given HTML element and wait for `animationend` event to execute callback * @param {HTMLElement} $el * @param {String} className * @param {Function} callback - A callback to run */ animateCSS($element, className, callback) { if ($element) { $element.dispatchEvent(new CustomEvent("animationend", { bubbles: true, cancelable: true })); } if (!$element || !className) { if (typeof callback === "function") { callback(); } return; } const handleAnimationEnd = function (event) { if (event.currentTarget === this) { $element.removeEventListener("animationend", handleAnimationEnd); if (callback) { callback(); } $element.classList.remove(className); } }; $element.addEventListener("animationend", handleAnimationEnd); $element.classList.add(className); } /** * Mark given slide as `done`, e.g., content is loaded and displayed completely * @param {Object} slide - Carousel slide */ done(slide) { slide.state = "done"; this.trigger("done", slide); // Trigger focus for current slide (and ignore preloaded slides) const currentSlide = this.getSlide(); if (currentSlide && slide.index === currentSlide.index && this.option("autoFocus")) { this.focus(); } } /** * Set error message as slide content * @param {Object} slide - Carousel slide * @param {String} message - Error message, can contain HTML code and template variables */ setError(slide, message) { slide.error = message; this.hideLoading(slide); this.clearContent(slide); // Create new content const div = document.createElement("div"); div.classList.add("fancybox-error"); div.innerHTML = this.localize(message || "<p>{{ERROR}}</p>"); this.setContent(slide, div, { suffix: "error" }); } /** * Create loading indicator inside given slide * @param {Object} slide - Carousel slide */ showLoading(slide) { slide.state = "loading"; slide.$el.classList.add("is-loading"); let $spinner = slide.$el.querySelector(".fancybox__spinner"); if ($spinner) { return; } $spinner = document.createElement("div"); $spinner.classList.add("fancybox__spinner"); $spinner.innerHTML = this.option("template.spinner"); $spinner.addEventListener("click", () => { if (!this.Carousel.Panzoom.velocity) this.close(); }); slide.$el.prepend($spinner); } /** * Remove loading indicator from given slide * @param {Object} slide - Carousel slide */ hideLoading(slide) { const $spinner = slide.$el && slide.$el.querySelector(".fancybox__spinner"); if ($spinner) { $spinner.remove(); slide.$el.classList.remove("is-loading"); } if (slide.state === "loading") { this.trigger("load", slide); slide.state = "ready"; } } /** * Slide carousel to next page */ next() { const carousel = this.Carousel; if (carousel && carousel.pages.length > 1) { carousel.slideNext(); } } /** * Slide carousel to previous page */ prev() { const carousel = this.Carousel; if (carousel && carousel.pages.length > 1) { carousel.slidePrev(); } } /** * Slide carousel to selected page with optional parameters * Examples: * Fancybox.getInstance().jumpTo(2); * Fancybox.getInstance().jumpTo(3, {friction: 0}) * @param {...any} args - Arguments for Carousel `slideTo` method */ jumpTo(...args) { if (this.Carousel) this.Carousel.slideTo(...args); } /** * Start closing the current instance * @param {Event} [event] - Optional click event */ close(event) { if (event) event.preventDefault(); // First, stop further execution if this instance is already closing // (this can happen if, for example, user clicks close button multiple times really fast) if (["closing", "customClosing", "destroy"].includes(this.state)) { return; } // Allow callbacks and/or plugins to prevent closing if (this.trigger("shouldClose", event) === false) { return; } this.state = "closing"; this.Carousel.Panzoom.destroy(); this.detachEvents(); this.trigger("closing", event); if (this.state === "destroy") { return; } // Trigger default CSS closing animation for backdrop and interface elements this.$container.setAttribute("aria-hidden", "true"); this.$container.classList.add("is-closing"); // Clear inactive slides const currentSlide = this.getSlide(); this.Carousel.slides.forEach((slide) => { if (slide.$content && slide.index !== currentSlide.index) { this.Carousel.trigger("removeSlide", slide); } }); // Start default closing animation if (this.state === "closing") { const hideClass = currentSlide.hideClass === undefined ? this.option("hideClass") : currentSlide.hideClass; this.animateCSS( currentSlide.$content, hideClass, () => { this.destroy(); }, true ); } } /** * Clean up after closing fancybox */ destroy() { if (this.state === "destroy") { return; } this.state = "destroy"; this.trigger("destroy"); const $trigger = this.option("placeFocusBack") ? this.getSlide().$trigger : null; // Destroy Carousel and then detach plugins; // * Note: this order allows plugins to receive `removeSlide` event this.Carousel.destroy(); this.detachPlugins(); this.Carousel = null; this.options = {}; this.events = {}; this.$container.remove(); this.$container = this.$backdrop = this.$carousel = null; if ($trigger) { setFocusOn($trigger); } instances.delete(this.id); const nextInstance = Fancybox.getInstance(); if (nextInstance) { nextInstance.focus(); return; } document.documentElement.classList.remove("with-fancybox"); document.body.classList.remove("is-using-mouse"); this.revealScrollbar(); } /** * Create new Fancybox instance with provided options * Example: * Fancybox.show([{ src : 'https://lipsum.app/id/1/300x225' }]); * @param {Array} items - Gallery items * @param {Object} [options] - Optional custom options * @returns {Object} Fancybox instance */ static show(items, options = {}) { return new Fancybox(items, options); } /** * Starts Fancybox if event target matches any opener or target is `trigger element` * @param {Event} event - Click event * @param {Object} [options] - Optional custom options */ static fromEvent(event, options = {}) { // Allow other scripts to prevent starting fancybox on click if (event.defaultPrevented) { return; } // Don't run if right-click if (event.button && event.button !== 0) { return; } // Ignore command/control + click if (event.ctrlKey || event.metaKey || event.shiftKey) { return; } const origTarget = event.composedPath()[0]; let eventTarget = origTarget; // Support `trigger` element, e.g., start fancybox from different DOM element, for example, // to have one preview image for hidden image gallery let triggerGroupName; if ( eventTarget.matches("[data-fancybox-trigger]") || (eventTarget = eventTarget.closest("[data-fancybox-trigger]")) ) { triggerGroupName = eventTarget && eventTarget.dataset && eventTarget.dataset.fancyboxTrigger; } if (triggerGroupName) { const triggerItems = document.querySelectorAll(`[data-fancybox="${triggerGroupName}"]`); const triggerIndex = parseInt(eventTarget.dataset.fancyboxIndex, 10) || 0; eventTarget = triggerItems.length ? triggerItems[triggerIndex] : eventTarget; } if (!eventTarget) { eventTarget = origTarget; } // * Try to find matching openener let matchingOpener; let target; Array.from(Fancybox.openers.keys()) .reverse() .some((opener) => { target = eventTarget; let found = false; try { if (target instanceof Element && (typeof opener === "string" || opener instanceof String)) { // Chain closest() to event.target to find and return the parent element, // regardless if clicking on the child elements (icon, label, etc) found = target.matches(opener) || (target = target.closest(opener)); } } catch (error) {} if (found) { event.preventDefault(); matchingOpener = opener; return true; } return false; }); let rez = false; if (matchingOpener) { options.event = event; options.target = target; target.origTarget = origTarget; rez = Fancybox.fromOpener(matchingOpener, options); // Check if the mouse is being used // Waiting for better browser support for `:focus-visible` - // https://drafts.csswg.org/selectors-4/#the-focus-visible-pseudo const nextInstance = Fancybox.getInstance(); if (nextInstance && nextInstance.state === "ready" && event.detail) { document.body.classList.add("is-using-mouse"); } } return rez; } /** * Starts Fancybox using selector * @param {String} opener - Valid CSS selector string * @param {Object} [options] - Optional custom options */ static fromOpener(opener, options = {}) { // Callback function called once for each group element that // 1) converts data attributes to boolean or JSON // 2) removes values that could cause issues const mapCallback = function (el) { const falseValues = ["false", "0", "no", "null", "undefined"]; const trueValues = ["true", "1", "yes"]; const dataset = Object.assign({}, el.dataset); const options = {}; for (let [key, value] of Object.entries(dataset)) { if (key === "fancybox") { continue; } if (key === "width" || key === "height") { options[`_${key}`] = value; } else if (typeof value === "string" || value instanceof String) { if (falseValues.indexOf(value) > -1) { options[key] = false; } else if (trueValues.indexOf(options[key]) > -1) { options[key] = true; } else { try { options[key] = JSON.parse(value); } catch (e) { options[key] = value; } } } else { options[key] = value; } } if (el instanceof Element) { options.$trigger = el; } return options; }; let items = [], index = options.startIndex || 0, target = options.target || null; // Get options // === options = extend({}, options, Fancybox.openers.get(opener)); // Get matching nodes // === const groupAll = options.groupAll === undefined ? false : options.groupAll; const groupAttr = options.groupAttr === undefined ? "data-fancybox" : options.groupAttr; const groupValue = groupAttr && target ? target.getAttribute(`${groupAttr}`) : ""; if (!target || groupValue || groupAll) { const $root = options.root || (target ? target.getRootNode() : document.body); items = [].slice.call($root.querySelectorAll(opener)); } if (target && !groupAll) { if (groupValue) { items = items.filter((el) => el.getAttribute(`${groupAttr}`) === groupValue); } else { items = [target]; } } if (!items.length) { return false; } // Exit if current instance is triggered from the same element // === const currentInstance = Fancybox.getInstance(); if (currentInstance && items.indexOf(currentInstance.options.$trigger) > -1) { return false; } // Start Fancybox // === // Get index of current item in the gallery index = target ? items.indexOf(target) : index; // Convert items in a format supported by fancybox items = items.map(mapCallback); // * Create new fancybox instance return new Fancybox( items, extend({}, options, { startIndex: index, $trigger: target, }) ); } /** * Attach a click handler function that starts Fancybox to the selected items, as well as to all future matching elements. * @param {String} selector - Selector that should match trigger elements * @param {Object} [options] - Custom options */ static bind(selector, options = {}) { function attachClickEvent() { document.body.addEventListener("click", Fancybox.fromEvent, false); } if (!canUseDOM) { return; } if (!Fancybox.openers.size) { if (/complete|interactive|loaded/.test(document.readyState)) { attachClickEvent(); } else { document.addEventListener("DOMContentLoaded", attachClickEvent); } } Fancybox.openers.set(selector, options); } /** * Remove the click handler that was attached with `bind()` * @param {String} selector - A selector which should match the one originally passed to .bind() */ static unbind(selector) { Fancybox.openers.delete(selector); if (!Fancybox.openers.size) { Fancybox.destroy(); } } /** * Immediately destroy all instances (without closing animation) and clean up all bindings.. */ static destroy() { let fb; while ((fb = Fancybox.getInstance())) { fb.destroy(); } Fancybox.openers = new Map(); document.body.removeEventListener("click", Fancybox.fromEvent, false); } /** * Retrieve instance by identifier or the top most instance, if identifier is not provided * @param {String|Numeric} [id] - Optional instance identifier */ static getInstance(id) { if (id) { return instances.get(id); } const instance = Array.from(instances.values()) .reverse() .find((instance) => { if (!["closing", "customClosing", "destroy"].includes(instance.state)) { return instance; } return false; }); return instance || null; } /** * Close all or topmost currently active instance. * @param {boolean} [all] - All or only topmost active instance * @param {any} [arguments] - Optional data */ static close(all = true, args) { if (all) { for (const instance of instances.values()) { instance.close(args); } } else { const instance = Fancybox.getInstance(); if (instance) { instance.close(args); } } } /** * Slide topmost currently active instance to next page */ static next() { const instance = Fancybox.getInstance(); if (instance) { instance.next(); } } /** * Slide topmost currently active instance to previous page */ static prev() { const instance = Fancybox.getInstance(); if (instance) { instance.prev(); } } } // Expose version Fancybox.version = "__VERSION__"; // Expose defaults Fancybox.defaults = defaults; // Expose openers Fancybox.openers = new Map(); // Add default plugins Fancybox.Plugins = Plugins; // Auto init with default options Fancybox.bind("[data-fancybox]"); // Prepare plugins for (const [key, Plugin] of Object.entries(Fancybox.Plugins || {})) { if (typeof Plugin.create === "function") { Plugin.create(Fancybox); } } export { Fancybox };