// var global = global || window; import { extend } from "../shared/utils/extend.js"; import { canUseDOM } from "../shared/utils/canUseDOM.js"; import { Base } from "../shared/Base/Base.js"; import { Carousel } from "../Carousel/Carousel.js"; import { Plugins } from "./plugins/index.js"; import en from "./l10n/en.js"; 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: '', // Loading indicator icon spinner: '', // Main container element main: null, }, /* Note: If the `template.main` option is not provided, the structure is generated as follows by default:
*/ // Localization of strings l10n: en, }; let called = 0; class Fancybox extends Base { /** * Fancybox constructor * @constructs Fancybox * @param {Object} [options] - Options for Fancybox */ constructor(items, options = {}) { 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(); // "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"); // Focus on the first focus element in this instance if (this.option("trapFocus")) { this.focus(); } } /** * Bind event handlers for referencability */ bindHandlers() { for (const methodName of [ "onMousedown", "onKeydown", "onClick", "onCreateSlide", "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); this.$container.addEventListener("click", this.onClick); } /** * Removes previously registered event listeners */ detachEvents() { document.removeEventListener("mousedown", this.onMousedown); document.removeEventListener("keydown", this.onKeydown); 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", "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.options.mainClass; if (mainClass) { this.$container.classList.add(...mainClass.split(" ")); } // Add class name for 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 `{{ERROR}}
"); 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"].indexOf(this.state) > -1) { 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() { 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) { // `preventScroll` option is not yet supported by Safari // https://bugs.webkit.org/show_bug.cgi?id=178583 if (Fancybox.preventScrollSupported) { $trigger.focus({ preventScroll: true }); } else { const scrollTop = document.body.scrollTop; // Save position $trigger.focus(); document.body.scrollTop = scrollTop; } } 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; } // Support `trigger` element, e.g., start fancybox from different DOM element, for example, // to have one preview image for hidden image gallery let eventTarget = event.target; 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 = event.target; } // * Try to find matching openener let matchingOpener; let target; Array.from(Fancybox.openers.keys()) .reverse() .some((opener) => { target = eventTarget; // Chain closest() to event.target to find and return the parent element, // regardless if clicking on the child elements (icon, label, etc) if (!(target.matches(opener) || (target = target.closest(opener)))) { return; } event.preventDefault(); matchingOpener = opener; return true; }); let rez = false; if (matchingOpener) { options.event = event; options.target = target; target.origTarget = event.target; 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 options = Object.assign({}, el.dataset); for (let [key, value] of Object.entries(options)) { 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; } } } } delete options.fancybox; delete options.type; 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 groupAttr = options.groupAttr === undefined ? "data-fancybox" : options.groupAttr; const groupValue = groupAttr && target && target.getAttribute(`${groupAttr}`); const groupAll = options.groupAll === undefined ? false : options.groupAll; if (groupAll || groupValue) { items = [].slice.call(document.querySelectorAll(opener)); if (!groupAll) { 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; } // 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 = {}) { if (!canUseDOM) { return; } if (!Fancybox.openers.size) { document.body.addEventListener("click", Fancybox.fromEvent, false); // Pass self to plugins to avoid circular dependencies for (const [key, Plugin] of Object.entries(Fancybox.Plugins || {})) { Plugin.Fancybox = this; if (typeof Plugin.create === "function") { Plugin.create(); } } } 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) { let nodes = []; if (id) { nodes = [document.getElementById(`fancybox-${id}`)]; } else { nodes = Array.from(document.querySelectorAll(".fancybox__container")).reverse(); } for (const $container of nodes) { const instance = $container && $container.Fancybox; if (instance && instance.state !== "closing" && instance.state !== "customClosing") { return instance; } } return null; } /** * Close all or topmost currently active instance. * @param {boolean} [all] - All or only topmost active instance */ static close(all = true) { let instance = null; while ((instance = Fancybox.getInstance())) { instance.close(); if (!all) return; } } } // 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]"); export { Fancybox };