UNPKG

41.5 kBJavaScriptView Raw
1import { extend } from "../shared/utils/extend.js";
2import { canUseDOM } from "../shared/utils/canUseDOM.js";
3import { FOCUSABLE_ELEMENTS, setFocusOn } from "../shared/utils/setFocusOn.js";
4
5import { Base } from "../shared/Base/Base.js";
6
7import { Carousel } from "../Carousel/Carousel.js";
8
9import { Plugins } from "./plugins/index.js";
10
11// Default language
12import en from "./l10n/en.js";
13
14// Default settings
15const defaults = {
16 // Index of active slide on the start
17 startIndex: 0,
18
19 // Number of slides to preload before and after active slide
20 preload: 1,
21
22 // Should navigation be infinite
23 infinite: true,
24
25 // Class name to be applied to the content to reveal it
26 showClass: "fancybox-zoomInUp", // "fancybox-fadeIn" | "fancybox-zoomInUp" | false
27
28 // Class name to be applied to the content to hide it
29 hideClass: "fancybox-fadeOut", // "fancybox-fadeOut" | "fancybox-zoomOutDown" | false
30
31 // Should backdrop and UI elements fade in/out on start/close
32 animated: true,
33
34 // If browser scrollbar should be hidden
35 hideScrollbar: true,
36
37 // Element containing main structure
38 parentEl: null,
39
40 // Custom class name or multiple space-separated class names for the container
41 mainClass: null,
42
43 // Set focus on first focusable element after displaying content
44 autoFocus: true,
45
46 // Trap focus inside Fancybox
47 trapFocus: true,
48
49 // Set focus back to trigger element after closing Fancybox
50 placeFocusBack: true,
51
52 // Action to take when the user clicks on the backdrop
53 click: "close", // "close" | "next" | null
54
55 // Position of the close button - over the content or at top right corner of viewport
56 closeButton: "inside", // "inside" | "outside"
57
58 // Allow user to drag content up/down to close instance
59 dragToClose: true,
60
61 // Enable keyboard navigation
62 keyboard: {
63 Escape: "close",
64 Delete: "close",
65 Backspace: "close",
66 PageUp: "next",
67 PageDown: "prev",
68 ArrowUp: "next",
69 ArrowDown: "prev",
70 ArrowRight: "next",
71 ArrowLeft: "prev",
72 },
73
74 // HTML templates for various elements
75 template: {
76 // Close button icon
77 closeButton:
78 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" tabindex="-1"><path d="M20 20L4 4m16 0L4 20"/></svg>',
79 // Loading indicator icon
80 spinner:
81 '<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>',
82
83 // Main container element
84 main: null,
85 },
86
87 /* Note: If the `template.main` option is not provided, the structure is generated as follows by default:
88 <div class="fancybox__container" role="dialog" aria-modal="true" aria-hidden="true" aria-label="{{MODAL}}" tabindex="-1">
89 <div class="fancybox__backdrop"></div>
90 <div class="fancybox__carousel"></div>
91 </div>
92 */
93
94 // Localization of strings
95 l10n: en,
96};
97
98// Object that contains all active instances of Fancybox
99const instances = new Map();
100
101// Number of Fancybox instances created, it is used to generate new instance "id"
102let called = 0;
103
104class Fancybox extends Base {
105 /**
106 * Fancybox constructor
107 * @constructs Fancybox
108 * @param {Object} [options] - Options for Fancybox
109 */
110 constructor(items, options = {}) {
111 // Quick hack to fix variable naming collision
112 items = items.map((item) => {
113 if (item.width) item._width = item.width;
114 if (item.height) item._height = item.height;
115
116 return item;
117 });
118
119 super(extend(true, {}, defaults, options));
120
121 this.bindHandlers();
122
123 this.state = "init";
124
125 this.setItems(items);
126
127 this.attachPlugins(Fancybox.Plugins);
128
129 // "init" event marks the start of initialization and is available to plugins
130 this.trigger("init");
131
132 if (this.option("hideScrollbar") === true) {
133 this.hideScrollbar();
134 }
135
136 this.initLayout();
137
138 this.initCarousel();
139
140 this.attachEvents();
141
142 instances.set(this.id, this);
143
144 // "prepare" event will trigger the creation of additional layout elements, such as thumbnails and toolbar
145 this.trigger("prepare");
146
147 this.state = "ready";
148
149 // "ready" event will trigger the content to load
150 this.trigger("ready");
151
152 // Reveal container
153 this.$container.setAttribute("aria-hidden", "false");
154
155 // Set focus on the first focusable element inside this instance
156 if (this.option("trapFocus")) {
157 this.focus();
158 }
159 }
160
161 /**
162 * Override `option` method to get value from the current slide
163 * @param {String} name option name
164 * @param {...any} rest optional extra parameters
165 * @returns {any}
166 */
167 option(name, ...rest) {
168 const slide = this.getSlide();
169
170 let value = slide ? slide[name] : undefined;
171
172 if (value !== undefined) {
173 if (typeof value === "function") {
174 value = value.call(this, this, ...rest);
175 }
176
177 return value;
178 }
179
180 return super.option(name, ...rest);
181 }
182
183 /**
184 * Bind event handlers for referencability
185 */
186 bindHandlers() {
187 for (const methodName of [
188 "onMousedown",
189 "onKeydown",
190 "onClick",
191
192 "onFocus",
193
194 "onCreateSlide",
195 "onSettle",
196
197 "onTouchMove",
198 "onTouchEnd",
199
200 "onTransform",
201 ]) {
202 this[methodName] = this[methodName].bind(this);
203 }
204 }
205
206 /**
207 * Set up a functions that will be called whenever the specified event is delivered
208 */
209 attachEvents() {
210 document.addEventListener("mousedown", this.onMousedown);
211 document.addEventListener("keydown", this.onKeydown, true);
212
213 // Trap keyboard focus inside of the modal
214 if (this.option("trapFocus")) {
215 document.addEventListener("focus", this.onFocus, true);
216 }
217
218 this.$container.addEventListener("click", this.onClick);
219 }
220
221 /**
222 * Removes previously registered event listeners
223 */
224 detachEvents() {
225 document.removeEventListener("mousedown", this.onMousedown);
226 document.removeEventListener("keydown", this.onKeydown, true);
227
228 document.removeEventListener("focus", this.onFocus, true);
229
230 this.$container.removeEventListener("click", this.onClick);
231 }
232
233 /**
234 * Initialize layout; create main container, backdrop nd layout for main carousel
235 */
236 initLayout() {
237 this.$root = this.option("parentEl") || document.body;
238
239 // Container
240 let mainTemplate = this.option("template.main");
241
242 if (mainTemplate) {
243 this.$root.insertAdjacentHTML("beforeend", this.localize(mainTemplate));
244
245 this.$container = this.$root.querySelector(".fancybox__container");
246 }
247
248 if (!this.$container) {
249 this.$container = document.createElement("div");
250 this.$root.appendChild(this.$container);
251 }
252
253 // Normally we would not need this, but Safari does not support `preventScroll:false` option for `focus` method
254 // and that causes layout issues
255 this.$container.onscroll = () => {
256 this.$container.scrollLeft = 0;
257 return false;
258 };
259
260 Object.entries({
261 class: "fancybox__container",
262 role: "dialog",
263 tabIndex: "-1",
264 "aria-modal": "true",
265 "aria-hidden": "true",
266 "aria-label": this.localize("{{MODAL}}"),
267 }).forEach((args) => this.$container.setAttribute(...args));
268
269 if (this.option("animated")) {
270 this.$container.classList.add("is-animated");
271 }
272
273 // Backdrop
274 this.$backdrop = this.$container.querySelector(".fancybox__backdrop");
275
276 if (!this.$backdrop) {
277 this.$backdrop = document.createElement("div");
278 this.$backdrop.classList.add("fancybox__backdrop");
279
280 this.$container.appendChild(this.$backdrop);
281 }
282
283 // Carousel
284 this.$carousel = this.$container.querySelector(".fancybox__carousel");
285
286 if (!this.$carousel) {
287 this.$carousel = document.createElement("div");
288 this.$carousel.classList.add("fancybox__carousel");
289
290 this.$container.appendChild(this.$carousel);
291 }
292
293 // Make instance reference accessible
294 this.$container.Fancybox = this;
295
296 // Make sure the container has an ID
297 this.id = this.$container.getAttribute("id");
298
299 if (!this.id) {
300 this.id = this.options.id || ++called;
301 this.$container.setAttribute("id", "fancybox-" + this.id);
302 }
303
304 // Add custom class name to main element
305 const mainClass = this.option("mainClass");
306
307 if (mainClass) {
308 this.$container.classList.add(...mainClass.split(" "));
309 }
310
311 // Add class name for <html> element
312 document.documentElement.classList.add("with-fancybox");
313
314 this.trigger("initLayout");
315
316 return this;
317 }
318
319 /**
320 * Prepares slides for the corousel
321 * @returns {Array} Slides
322 */
323 setItems(items) {
324 const slides = [];
325
326 for (const slide of items) {
327 const $trigger = slide.$trigger;
328
329 if ($trigger) {
330 const dataset = $trigger.dataset || {};
331
332 slide.src = dataset.src || $trigger.getAttribute("href") || slide.src;
333 slide.type = dataset.type || slide.type;
334
335 // Support items without `src`, e.g., when `data-fancybox` attribute added directly to `<img>` element
336 if (!slide.src && $trigger instanceof HTMLImageElement) {
337 slide.src = $trigger.currentSrc || slide.$trigger.src;
338 }
339 }
340
341 // Check for thumbnail element
342 let $thumb = slide.$thumb;
343
344 if (!$thumb) {
345 let origTarget = slide.$trigger && slide.$trigger.origTarget;
346
347 if (origTarget) {
348 if (origTarget instanceof HTMLImageElement) {
349 $thumb = origTarget;
350 } else {
351 $thumb = origTarget.querySelector("img:not([aria-hidden])");
352 }
353 }
354
355 if (!$thumb && slide.$trigger) {
356 $thumb =
357 slide.$trigger instanceof HTMLImageElement
358 ? slide.$trigger
359 : slide.$trigger.querySelector("img:not([aria-hidden])");
360 }
361 }
362
363 slide.$thumb = $thumb || null;
364
365 // Get thumbnail image source
366 let thumb = slide.thumb;
367
368 if (!thumb && $thumb) {
369 thumb = $thumb.currentSrc || $thumb.src;
370
371 if (!thumb && $thumb.dataset) {
372 thumb = $thumb.dataset.lazySrc || $thumb.dataset.src;
373 }
374 }
375
376 // Assume we have image, then use it as thumbnail
377 if (!thumb && slide.type === "image") {
378 thumb = slide.src;
379 }
380
381 slide.thumb = thumb || null;
382
383 // Add empty caption to make things simpler
384 slide.caption = slide.caption || "";
385
386 slides.push(slide);
387 }
388
389 this.items = slides;
390 }
391
392 /**
393 * Initialize main Carousel that will be used to display the content
394 * @param {Array} slides
395 */
396 initCarousel() {
397 this.Carousel = new Carousel(
398 this.$carousel,
399 extend(
400 true,
401 {},
402 {
403 prefix: "",
404
405 classNames: {
406 viewport: "fancybox__viewport",
407 track: "fancybox__track",
408 slide: "fancybox__slide",
409 },
410
411 textSelection: true,
412 preload: this.option("preload"),
413
414 friction: 0.88,
415
416 slides: this.items,
417 initialPage: this.options.startIndex,
418 slidesPerPage: 1,
419
420 infiniteX: this.option("infinite"),
421 infiniteY: true,
422
423 l10n: this.option("l10n"),
424
425 Dots: false,
426 Navigation: {
427 classNames: {
428 main: "fancybox__nav",
429 button: "carousel__button",
430
431 next: "is-next",
432 prev: "is-prev",
433 },
434 },
435
436 Panzoom: {
437 textSelection: true,
438
439 panOnlyZoomed: () => {
440 return (
441 this.Carousel && this.Carousel.pages && this.Carousel.pages.length < 2 && !this.option("dragToClose")
442 );
443 },
444
445 lockAxis: () => {
446 if (this.Carousel) {
447 let rez = "x";
448
449 if (this.option("dragToClose")) {
450 rez += "y";
451 }
452
453 return rez;
454 }
455 },
456 },
457
458 on: {
459 "*": (name, ...details) => this.trigger(`Carousel.${name}`, ...details),
460 init: (carousel) => (this.Carousel = carousel),
461 createSlide: this.onCreateSlide,
462 settle: this.onSettle,
463 },
464 },
465
466 this.option("Carousel")
467 )
468 );
469
470 if (this.option("dragToClose")) {
471 this.Carousel.Panzoom.on({
472 // Stop further touch event handling if content is scaled
473 touchMove: this.onTouchMove,
474
475 // Update backdrop opacity depending on vertical distance
476 afterTransform: this.onTransform,
477
478 // Close instance if drag distance exceeds limit
479 touchEnd: this.onTouchEnd,
480 });
481 }
482
483 this.trigger("initCarousel");
484
485 return this;
486 }
487
488 /**
489 * Process `createSlide` event to create caption element inside new slide
490 */
491 onCreateSlide(carousel, slide) {
492 let caption = slide.caption || "";
493
494 if (typeof this.options.caption === "function") {
495 caption = this.options.caption.call(this, this, this.Carousel, slide);
496 }
497
498 if (typeof caption === "string" && caption.length) {
499 const $caption = document.createElement("div");
500 const id = `fancybox__caption_${this.id}_${slide.index}`;
501
502 $caption.className = "fancybox__caption";
503 $caption.innerHTML = caption;
504 $caption.setAttribute("id", id);
505
506 slide.$caption = slide.$el.appendChild($caption);
507
508 slide.$el.classList.add("has-caption");
509 slide.$el.setAttribute("aria-labelledby", id);
510 }
511 }
512
513 /**
514 * Handle Carousel `settle` event
515 */
516 onSettle() {
517 if (this.option("autoFocus")) {
518 this.focus();
519 }
520 }
521
522 /**
523 * Handle focus event
524 * @param {Event} event - Focus event
525 */
526 onFocus(event) {
527 this.focus(event);
528 }
529
530 /**
531 * Handle click event on the container
532 * @param {Event} event - Click event
533 */
534 onClick(event) {
535 if (event.defaultPrevented) {
536 return;
537 }
538
539 let eventTarget = event.composedPath()[0];
540
541 if (eventTarget.matches("[data-fancybox-close]")) {
542 event.preventDefault();
543 Fancybox.close(false, event);
544
545 return;
546 }
547
548 if (eventTarget.matches("[data-fancybox-next]")) {
549 event.preventDefault();
550 Fancybox.next();
551
552 return;
553 }
554
555 if (eventTarget.matches("[data-fancybox-prev]")) {
556 event.preventDefault();
557 Fancybox.prev();
558
559 return;
560 }
561
562 if (!eventTarget.matches(FOCUSABLE_ELEMENTS)) {
563 document.activeElement.blur();
564 }
565
566 // Skip if clicked inside content area
567 if (eventTarget.closest(".fancybox__content")) {
568 return;
569 }
570
571 // Skip if text is selected
572 if (getSelection().toString().length) {
573 return;
574 }
575
576 if (this.trigger("click", event) === false) {
577 return;
578 }
579
580 const action = this.option("click");
581
582 switch (action) {
583 case "close":
584 this.close();
585 break;
586 case "next":
587 this.next();
588 break;
589 }
590 }
591
592 /**
593 * Handle panzoom `touchMove` event; Disable dragging if content of current slide is scaled
594 */
595 onTouchMove() {
596 const panzoom = this.getSlide().Panzoom;
597
598 return panzoom && panzoom.content.scale !== 1 ? false : true;
599 }
600
601 /**
602 * Handle panzoom `touchEnd` event; close when quick flick up/down is detected
603 * @param {Object} panzoom - Panzoom instance
604 */
605 onTouchEnd(panzoom) {
606 const distanceY = panzoom.dragOffset.y;
607
608 if (Math.abs(distanceY) >= 150 || (Math.abs(distanceY) >= 35 && panzoom.dragOffset.time < 350)) {
609 if (this.option("hideClass")) {
610 this.getSlide().hideClass = `fancybox-throwOut${panzoom.content.y < 0 ? "Up" : "Down"}`;
611 }
612
613 this.close();
614 } else if (panzoom.lockAxis === "y") {
615 panzoom.panTo({ y: 0 });
616 }
617 }
618
619 /**
620 * Handle `afterTransform` event; change backdrop opacity based on current y position of panzoom
621 * @param {Object} panzoom - Panzoom instance
622 */
623 onTransform(panzoom) {
624 const $backdrop = this.$backdrop;
625
626 if ($backdrop) {
627 const yPos = Math.abs(panzoom.content.y);
628 const opacity = yPos < 1 ? "" : Math.max(0.33, Math.min(1, 1 - (yPos / panzoom.content.fitHeight) * 1.5));
629
630 this.$container.style.setProperty("--fancybox-ts", opacity ? "0s" : "");
631 this.$container.style.setProperty("--fancybox-opacity", opacity);
632 }
633 }
634
635 /**
636 * Handle `mousedown` event to mark that the mouse is in use
637 */
638 onMousedown() {
639 if (this.state === "ready") {
640 document.body.classList.add("is-using-mouse");
641 }
642 }
643
644 /**
645 * Handle `keydown` event; trap focus
646 * @param {Event} event Keydown event
647 */
648 onKeydown(event) {
649 if (Fancybox.getInstance().id !== this.id) {
650 return;
651 }
652
653 document.body.classList.remove("is-using-mouse");
654
655 const key = event.key;
656 const keyboard = this.option("keyboard");
657
658 if (!keyboard || event.ctrlKey || event.altKey || event.shiftKey) {
659 return;
660 }
661
662 const target = event.composedPath()[0];
663
664 const classList = document.activeElement && document.activeElement.classList;
665 const isUIElement = classList && classList.contains("carousel__button");
666
667 // Allow to close using Escape button
668 if (key !== "Escape" && !isUIElement) {
669 let ignoreElements =
670 event.target.isContentEditable ||
671 ["BUTTON", "TEXTAREA", "OPTION", "INPUT", "SELECT", "VIDEO"].indexOf(target.nodeName) !== -1;
672
673 if (ignoreElements) {
674 return;
675 }
676 }
677
678 if (this.trigger("keydown", key, event) === false) {
679 return;
680 }
681
682 const action = keyboard[key];
683
684 if (typeof this[action] === "function") {
685 this[action]();
686 }
687 }
688
689 /**
690 * Get the active slide. This will be the first slide from the current page of the main carousel.
691 */
692 getSlide() {
693 const carousel = this.Carousel;
694
695 if (!carousel) return null;
696
697 const page = carousel.page === null ? carousel.option("initialPage") : carousel.page;
698 const pages = carousel.pages || [];
699
700 if (pages.length && pages[page]) {
701 return pages[page].slides[0];
702 }
703
704 return null;
705 }
706
707 /**
708 * Place focus on the first focusable element inside current slide
709 * @param {Event} [event] - Focus event
710 */
711 focus(event) {
712 if (Fancybox.ignoreFocusChange) {
713 return;
714 }
715
716 if (["init", "closing", "customClosing", "destroy"].indexOf(this.state) > -1) {
717 return;
718 }
719
720 const $container = this.$container;
721 const currentSlide = this.getSlide();
722 const $currentSlide = currentSlide.state === "done" ? currentSlide.$el : null;
723
724 // Skip if the DOM element that is currently in focus is already inside the current slide
725 if ($currentSlide && $currentSlide.contains(document.activeElement)) {
726 return;
727 }
728
729 if (event) {
730 event.preventDefault();
731 }
732
733 Fancybox.ignoreFocusChange = true;
734
735 const allFocusableElems = Array.from($container.querySelectorAll(FOCUSABLE_ELEMENTS));
736
737 let enabledElems = [];
738 let $firstEl;
739
740 for (let node of allFocusableElems) {
741 // Enable element if it's visible and
742 // is inside the current slide or is outside main carousel (for example, inside the toolbar)
743 const isNodeVisible = node.offsetParent;
744 const isNodeInsideCurrentSlide = $currentSlide && $currentSlide.contains(node);
745 const isNodeOutsideCarousel = !this.Carousel.$viewport.contains(node);
746
747 if (isNodeVisible && (isNodeInsideCurrentSlide || isNodeOutsideCarousel)) {
748 enabledElems.push(node);
749
750 if (node.dataset.origTabindex !== undefined) {
751 node.tabIndex = node.dataset.origTabindex;
752 node.removeAttribute("data-orig-tabindex");
753 }
754
755 if (
756 node.hasAttribute("autoFocus") ||
757 (!$firstEl && isNodeInsideCurrentSlide && !node.classList.contains("carousel__button"))
758 ) {
759 $firstEl = node;
760 }
761 } else {
762 // Element is either hidden or is inside preloaded slide (e.g., not inside current slide, but next/prev)
763 node.dataset.origTabindex =
764 node.dataset.origTabindex === undefined ? node.getAttribute("tabindex") : node.dataset.origTabindex;
765
766 node.tabIndex = -1;
767 }
768 }
769
770 if (!event) {
771 if (this.option("autoFocus") && $firstEl) {
772 setFocusOn($firstEl);
773 } else if (enabledElems.indexOf(document.activeElement) < 0) {
774 setFocusOn($container);
775 }
776 } else {
777 if (enabledElems.indexOf(event.target) > -1) {
778 this.lastFocus = event.target;
779 } else {
780 if (this.lastFocus === $container) {
781 setFocusOn(enabledElems[enabledElems.length - 1]);
782 } else {
783 setFocusOn($container);
784 }
785 }
786 }
787
788 this.lastFocus = document.activeElement;
789
790 Fancybox.ignoreFocusChange = false;
791 }
792
793 /**
794 * Hide vertical page scrollbar and adjust right padding value of `body` element to prevent content from shifting
795 * (otherwise the `body` element may become wider and the content may expand horizontally).
796 */
797 hideScrollbar() {
798 if (!canUseDOM) {
799 return;
800 }
801
802 const scrollbarWidth = window.innerWidth - document.documentElement.getBoundingClientRect().width;
803 const id = "fancybox-style-noscroll";
804
805 let $style = document.getElementById(id);
806
807 if ($style) {
808 return;
809 }
810
811 if (scrollbarWidth > 0) {
812 $style = document.createElement("style");
813
814 $style.id = id;
815 $style.type = "text/css";
816 $style.innerHTML = `.compensate-for-scrollbar {padding-right: ${scrollbarWidth}px;}`;
817
818 document.getElementsByTagName("head")[0].appendChild($style);
819
820 document.body.classList.add("compensate-for-scrollbar");
821 }
822 }
823
824 /**
825 * Stop hiding vertical page scrollbar
826 */
827 revealScrollbar() {
828 document.body.classList.remove("compensate-for-scrollbar");
829
830 const el = document.getElementById("fancybox-style-noscroll");
831
832 if (el) {
833 el.remove();
834 }
835 }
836
837 /**
838 * Remove content for given slide
839 * @param {Object} slide - Carousel slide
840 */
841 clearContent(slide) {
842 // * Clear previously added content and class name
843 this.Carousel.trigger("removeSlide", slide);
844
845 if (slide.$content) {
846 slide.$content.remove();
847 slide.$content = null;
848 }
849
850 if (slide.$closeButton) {
851 slide.$closeButton.remove();
852 slide.$closeButton = null;
853 }
854
855 if (slide._className) {
856 slide.$el.classList.remove(slide._className);
857 }
858 }
859
860 /**
861 * Set new content for given slide
862 * @param {Object} slide - Carousel slide
863 * @param {HTMLElement|String} html - HTML element or string containing HTML code
864 * @param {Object} [opts] - Options
865 */
866 setContent(slide, html, opts = {}) {
867 let $content;
868
869 const $el = slide.$el;
870
871 if (html instanceof HTMLElement) {
872 if (["img", "iframe", "video", "audio"].indexOf(html.nodeName.toLowerCase()) > -1) {
873 $content = document.createElement("div");
874 $content.appendChild(html);
875 } else {
876 $content = html;
877 }
878 } else {
879 const $fragment = document.createRange().createContextualFragment(html);
880
881 $content = document.createElement("div");
882 $content.appendChild($fragment);
883 }
884
885 if (slide.filter && !slide.error) {
886 $content = $content.querySelector(slide.filter);
887 }
888
889 if (!($content instanceof Element)) {
890 this.setError(slide, "{{ELEMENT_NOT_FOUND}}");
891
892 return;
893 }
894
895 // * Add class name indicating content type, for example `has-image`
896 slide._className = `has-${opts.suffix || slide.type || "unknown"}`;
897
898 $el.classList.add(slide._className);
899
900 // * Set content
901 $content.classList.add("fancybox__content");
902
903 // Make sure that content is not hidden and will be visible
904 if ($content.style.display === "none" || getComputedStyle($content).getPropertyValue("display") === "none") {
905 $content.style.display = slide.display || this.option("defaultDisplay") || "flex";
906 }
907
908 if (slide.id) {
909 $content.setAttribute("id", slide.id);
910 }
911
912 slide.$content = $content;
913
914 $el.prepend($content);
915
916 this.manageCloseButton(slide);
917
918 if (slide.state !== "loading") {
919 this.revealContent(slide);
920 }
921
922 return $content;
923 }
924
925 /**
926 * Create close button if needed
927 * @param {Object} slide
928 */
929 manageCloseButton(slide) {
930 const position = slide.closeButton === undefined ? this.option("closeButton") : slide.closeButton;
931
932 if (!position || (position === "top" && this.$closeButton)) {
933 return;
934 }
935
936 const $btn = document.createElement("button");
937
938 $btn.classList.add("carousel__button", "is-close");
939 $btn.setAttribute("title", this.options.l10n.CLOSE);
940 $btn.innerHTML = this.option("template.closeButton");
941
942 $btn.addEventListener("click", (e) => this.close(e));
943
944 if (position === "inside") {
945 // Remove existing one to avoid scope issues
946 if (slide.$closeButton) {
947 slide.$closeButton.remove();
948 }
949
950 slide.$closeButton = slide.$content.appendChild($btn);
951 } else {
952 this.$closeButton = this.$container.insertBefore($btn, this.$container.firstChild);
953 }
954 }
955
956 /**
957 * Make content visible for given slide and optionally start CSS animation
958 * @param {Object} slide - Carousel slide
959 */
960 revealContent(slide) {
961 this.trigger("reveal", slide);
962
963 slide.$content.style.visibility = "";
964
965 // Add CSS class name that reveals content (default animation is "fadeIn")
966 let showClass = false;
967
968 if (
969 !(
970 slide.error ||
971 slide.state === "loading" ||
972 this.Carousel.prevPage !== null ||
973 slide.index !== this.options.startIndex
974 )
975 ) {
976 showClass = slide.showClass === undefined ? this.option("showClass") : slide.showClass;
977 }
978
979 if (!showClass) {
980 this.done(slide);
981
982 return;
983 }
984
985 slide.state = "animating";
986
987 this.animateCSS(slide.$content, showClass, () => {
988 this.done(slide);
989 });
990 }
991
992 /**
993 * Add class name to given HTML element and wait for `animationend` event to execute callback
994 * @param {HTMLElement} $el
995 * @param {String} className
996 * @param {Function} callback - A callback to run
997 */
998 animateCSS($element, className, callback) {
999 if ($element) {
1000 $element.dispatchEvent(new CustomEvent("animationend", { bubbles: true, cancelable: true }));
1001 }
1002
1003 if (!$element || !className) {
1004 if (typeof callback === "function") {
1005 callback();
1006 }
1007
1008 return;
1009 }
1010
1011 const handleAnimationEnd = function (event) {
1012 if (event.currentTarget === this) {
1013 $element.removeEventListener("animationend", handleAnimationEnd);
1014
1015 if (callback) {
1016 callback();
1017 }
1018
1019 $element.classList.remove(className);
1020 }
1021 };
1022
1023 $element.addEventListener("animationend", handleAnimationEnd);
1024 $element.classList.add(className);
1025 }
1026
1027 /**
1028 * Mark given slide as `done`, e.g., content is loaded and displayed completely
1029 * @param {Object} slide - Carousel slide
1030 */
1031 done(slide) {
1032 slide.state = "done";
1033
1034 this.trigger("done", slide);
1035
1036 // Trigger focus for current slide (and ignore preloaded slides)
1037 const currentSlide = this.getSlide();
1038
1039 if (currentSlide && slide.index === currentSlide.index && this.option("autoFocus")) {
1040 this.focus();
1041 }
1042 }
1043
1044 /**
1045 * Set error message as slide content
1046 * @param {Object} slide - Carousel slide
1047 * @param {String} message - Error message, can contain HTML code and template variables
1048 */
1049 setError(slide, message) {
1050 slide.error = message;
1051
1052 this.hideLoading(slide);
1053 this.clearContent(slide);
1054
1055 // Create new content
1056 const div = document.createElement("div");
1057 div.classList.add("fancybox-error");
1058 div.innerHTML = this.localize(message || "<p>{{ERROR}}</p>");
1059
1060 this.setContent(slide, div, { suffix: "error" });
1061 }
1062
1063 /**
1064 * Create loading indicator inside given slide
1065 * @param {Object} slide - Carousel slide
1066 */
1067 showLoading(slide) {
1068 slide.state = "loading";
1069
1070 slide.$el.classList.add("is-loading");
1071
1072 let $spinner = slide.$el.querySelector(".fancybox__spinner");
1073
1074 if ($spinner) {
1075 return;
1076 }
1077
1078 $spinner = document.createElement("div");
1079
1080 $spinner.classList.add("fancybox__spinner");
1081 $spinner.innerHTML = this.option("template.spinner");
1082
1083 $spinner.addEventListener("click", () => {
1084 if (!this.Carousel.Panzoom.velocity) this.close();
1085 });
1086
1087 slide.$el.prepend($spinner);
1088 }
1089
1090 /**
1091 * Remove loading indicator from given slide
1092 * @param {Object} slide - Carousel slide
1093 */
1094 hideLoading(slide) {
1095 const $spinner = slide.$el && slide.$el.querySelector(".fancybox__spinner");
1096
1097 if ($spinner) {
1098 $spinner.remove();
1099
1100 slide.$el.classList.remove("is-loading");
1101 }
1102
1103 if (slide.state === "loading") {
1104 this.trigger("load", slide);
1105
1106 slide.state = "ready";
1107 }
1108 }
1109
1110 /**
1111 * Slide carousel to next page
1112 */
1113 next() {
1114 const carousel = this.Carousel;
1115
1116 if (carousel && carousel.pages.length > 1) {
1117 carousel.slideNext();
1118 }
1119 }
1120
1121 /**
1122 * Slide carousel to previous page
1123 */
1124 prev() {
1125 const carousel = this.Carousel;
1126
1127 if (carousel && carousel.pages.length > 1) {
1128 carousel.slidePrev();
1129 }
1130 }
1131
1132 /**
1133 * Slide carousel to selected page with optional parameters
1134 * Examples:
1135 * Fancybox.getInstance().jumpTo(2);
1136 * Fancybox.getInstance().jumpTo(3, {friction: 0})
1137 * @param {...any} args - Arguments for Carousel `slideTo` method
1138 */
1139 jumpTo(...args) {
1140 if (this.Carousel) this.Carousel.slideTo(...args);
1141 }
1142
1143 /**
1144 * Start closing the current instance
1145 * @param {Event} [event] - Optional click event
1146 */
1147 close(event) {
1148 if (event) event.preventDefault();
1149
1150 // First, stop further execution if this instance is already closing
1151 // (this can happen if, for example, user clicks close button multiple times really fast)
1152 if (["closing", "customClosing", "destroy"].includes(this.state)) {
1153 return;
1154 }
1155
1156 // Allow callbacks and/or plugins to prevent closing
1157 if (this.trigger("shouldClose", event) === false) {
1158 return;
1159 }
1160
1161 this.state = "closing";
1162
1163 this.Carousel.Panzoom.destroy();
1164
1165 this.detachEvents();
1166
1167 this.trigger("closing", event);
1168
1169 if (this.state === "destroy") {
1170 return;
1171 }
1172
1173 // Trigger default CSS closing animation for backdrop and interface elements
1174 this.$container.setAttribute("aria-hidden", "true");
1175
1176 this.$container.classList.add("is-closing");
1177
1178 // Clear inactive slides
1179 const currentSlide = this.getSlide();
1180
1181 this.Carousel.slides.forEach((slide) => {
1182 if (slide.$content && slide.index !== currentSlide.index) {
1183 this.Carousel.trigger("removeSlide", slide);
1184 }
1185 });
1186
1187 // Start default closing animation
1188 if (this.state === "closing") {
1189 const hideClass = currentSlide.hideClass === undefined ? this.option("hideClass") : currentSlide.hideClass;
1190
1191 this.animateCSS(
1192 currentSlide.$content,
1193 hideClass,
1194 () => {
1195 this.destroy();
1196 },
1197 true
1198 );
1199 }
1200 }
1201
1202 /**
1203 * Clean up after closing fancybox
1204 */
1205 destroy() {
1206 if (this.state === "destroy") {
1207 return;
1208 }
1209
1210 this.state = "destroy";
1211
1212 this.trigger("destroy");
1213
1214 const $trigger = this.option("placeFocusBack") ? this.getSlide().$trigger : null;
1215
1216 // Destroy Carousel and then detach plugins;
1217 // * Note: this order allows plugins to receive `removeSlide` event
1218 this.Carousel.destroy();
1219
1220 this.detachPlugins();
1221
1222 this.Carousel = null;
1223
1224 this.options = {};
1225 this.events = {};
1226
1227 this.$container.remove();
1228
1229 this.$container = this.$backdrop = this.$carousel = null;
1230
1231 if ($trigger) {
1232 setFocusOn($trigger);
1233 }
1234
1235 instances.delete(this.id);
1236
1237 const nextInstance = Fancybox.getInstance();
1238
1239 if (nextInstance) {
1240 nextInstance.focus();
1241 return;
1242 }
1243
1244 document.documentElement.classList.remove("with-fancybox");
1245 document.body.classList.remove("is-using-mouse");
1246
1247 this.revealScrollbar();
1248 }
1249
1250 /**
1251 * Create new Fancybox instance with provided options
1252 * Example:
1253 * Fancybox.show([{ src : 'https://lipsum.app/id/1/300x225' }]);
1254 * @param {Array} items - Gallery items
1255 * @param {Object} [options] - Optional custom options
1256 * @returns {Object} Fancybox instance
1257 */
1258 static show(items, options = {}) {
1259 return new Fancybox(items, options);
1260 }
1261
1262 /**
1263 * Starts Fancybox if event target matches any opener or target is `trigger element`
1264 * @param {Event} event - Click event
1265 * @param {Object} [options] - Optional custom options
1266 */
1267 static fromEvent(event, options = {}) {
1268 // Allow other scripts to prevent starting fancybox on click
1269 if (event.defaultPrevented) {
1270 return;
1271 }
1272
1273 // Don't run if right-click
1274 if (event.button && event.button !== 0) {
1275 return;
1276 }
1277
1278 // Ignore command/control + click
1279 if (event.ctrlKey || event.metaKey || event.shiftKey) {
1280 return;
1281 }
1282
1283 const origTarget = event.composedPath()[0];
1284
1285 let eventTarget = origTarget;
1286
1287 // Support `trigger` element, e.g., start fancybox from different DOM element, for example,
1288 // to have one preview image for hidden image gallery
1289 let triggerGroupName;
1290
1291 if (
1292 eventTarget.matches("[data-fancybox-trigger]") ||
1293 (eventTarget = eventTarget.closest("[data-fancybox-trigger]"))
1294 ) {
1295 triggerGroupName = eventTarget && eventTarget.dataset && eventTarget.dataset.fancyboxTrigger;
1296 }
1297
1298 if (triggerGroupName) {
1299 const triggerItems = document.querySelectorAll(`[data-fancybox="${triggerGroupName}"]`);
1300 const triggerIndex = parseInt(eventTarget.dataset.fancyboxIndex, 10) || 0;
1301
1302 eventTarget = triggerItems.length ? triggerItems[triggerIndex] : eventTarget;
1303 }
1304
1305 if (!eventTarget) {
1306 eventTarget = origTarget;
1307 }
1308
1309 // * Try to find matching openener
1310 let matchingOpener;
1311 let target;
1312
1313 Array.from(Fancybox.openers.keys())
1314 .reverse()
1315 .some((opener) => {
1316 target = eventTarget;
1317
1318 let found = false;
1319
1320 try {
1321 if (target instanceof Element && (typeof opener === "string" || opener instanceof String)) {
1322 // Chain closest() to event.target to find and return the parent element,
1323 // regardless if clicking on the child elements (icon, label, etc)
1324 found = target.matches(opener) || (target = target.closest(opener));
1325 }
1326 } catch (error) {}
1327
1328 if (found) {
1329 event.preventDefault();
1330 matchingOpener = opener;
1331 return true;
1332 }
1333
1334 return false;
1335 });
1336
1337 let rez = false;
1338
1339 if (matchingOpener) {
1340 options.event = event;
1341 options.target = target;
1342
1343 target.origTarget = origTarget;
1344
1345 rez = Fancybox.fromOpener(matchingOpener, options);
1346
1347 // Check if the mouse is being used
1348 // Waiting for better browser support for `:focus-visible` -
1349 // https://drafts.csswg.org/selectors-4/#the-focus-visible-pseudo
1350 const nextInstance = Fancybox.getInstance();
1351
1352 if (nextInstance && nextInstance.state === "ready" && event.detail) {
1353 document.body.classList.add("is-using-mouse");
1354 }
1355 }
1356
1357 return rez;
1358 }
1359
1360 /**
1361 * Starts Fancybox using selector
1362 * @param {String} opener - Valid CSS selector string
1363 * @param {Object} [options] - Optional custom options
1364 */
1365 static fromOpener(opener, options = {}) {
1366 // Callback function called once for each group element that
1367 // 1) converts data attributes to boolean or JSON
1368 // 2) removes values that could cause issues
1369 const mapCallback = function (el) {
1370 const falseValues = ["false", "0", "no", "null", "undefined"];
1371 const trueValues = ["true", "1", "yes"];
1372
1373 const dataset = Object.assign({}, el.dataset);
1374 const options = {};
1375
1376 for (let [key, value] of Object.entries(dataset)) {
1377 if (key === "fancybox") {
1378 continue;
1379 }
1380
1381 if (key === "width" || key === "height") {
1382 options[`_${key}`] = value;
1383 } else if (typeof value === "string" || value instanceof String) {
1384 if (falseValues.indexOf(value) > -1) {
1385 options[key] = false;
1386 } else if (trueValues.indexOf(options[key]) > -1) {
1387 options[key] = true;
1388 } else {
1389 try {
1390 options[key] = JSON.parse(value);
1391 } catch (e) {
1392 options[key] = value;
1393 }
1394 }
1395 } else {
1396 options[key] = value;
1397 }
1398 }
1399
1400 if (el instanceof Element) {
1401 options.$trigger = el;
1402 }
1403
1404 return options;
1405 };
1406
1407 let items = [],
1408 index = options.startIndex || 0,
1409 target = options.target || null;
1410
1411 // Get options
1412 // ===
1413 options = extend({}, options, Fancybox.openers.get(opener));
1414
1415 // Get matching nodes
1416 // ===
1417 const groupAll = options.groupAll === undefined ? false : options.groupAll;
1418
1419 const groupAttr = options.groupAttr === undefined ? "data-fancybox" : options.groupAttr;
1420 const groupValue = groupAttr && target ? target.getAttribute(`${groupAttr}`) : "";
1421
1422 if (!target || groupValue || groupAll) {
1423 const $root = options.root || (target ? target.getRootNode() : document.body);
1424
1425 items = [].slice.call($root.querySelectorAll(opener));
1426 }
1427
1428 if (target && !groupAll) {
1429 if (groupValue) {
1430 items = items.filter((el) => el.getAttribute(`${groupAttr}`) === groupValue);
1431 } else {
1432 items = [target];
1433 }
1434 }
1435
1436 if (!items.length) {
1437 return false;
1438 }
1439
1440 // Exit if current instance is triggered from the same element
1441 // ===
1442 const currentInstance = Fancybox.getInstance();
1443
1444 if (currentInstance && items.indexOf(currentInstance.options.$trigger) > -1) {
1445 return false;
1446 }
1447
1448 // Start Fancybox
1449 // ===
1450
1451 // Get index of current item in the gallery
1452 index = target ? items.indexOf(target) : index;
1453
1454 // Convert items in a format supported by fancybox
1455 items = items.map(mapCallback);
1456
1457 // * Create new fancybox instance
1458 return new Fancybox(
1459 items,
1460 extend({}, options, {
1461 startIndex: index,
1462 $trigger: target,
1463 })
1464 );
1465 }
1466
1467 /**
1468 * Attach a click handler function that starts Fancybox to the selected items, as well as to all future matching elements.
1469 * @param {String} selector - Selector that should match trigger elements
1470 * @param {Object} [options] - Custom options
1471 */
1472 static bind(selector, options = {}) {
1473 function attachClickEvent() {
1474 document.body.addEventListener("click", Fancybox.fromEvent, false);
1475 }
1476
1477 if (!canUseDOM) {
1478 return;
1479 }
1480
1481 if (!Fancybox.openers.size) {
1482 if (/complete|interactive|loaded/.test(document.readyState)) {
1483 attachClickEvent();
1484 } else {
1485 document.addEventListener("DOMContentLoaded", attachClickEvent);
1486 }
1487 }
1488
1489 Fancybox.openers.set(selector, options);
1490 }
1491
1492 /**
1493 * Remove the click handler that was attached with `bind()`
1494 * @param {String} selector - A selector which should match the one originally passed to .bind()
1495 */
1496 static unbind(selector) {
1497 Fancybox.openers.delete(selector);
1498
1499 if (!Fancybox.openers.size) {
1500 Fancybox.destroy();
1501 }
1502 }
1503
1504 /**
1505 * Immediately destroy all instances (without closing animation) and clean up all bindings..
1506 */
1507 static destroy() {
1508 let fb;
1509
1510 while ((fb = Fancybox.getInstance())) {
1511 fb.destroy();
1512 }
1513
1514 Fancybox.openers = new Map();
1515
1516 document.body.removeEventListener("click", Fancybox.fromEvent, false);
1517 }
1518
1519 /**
1520 * Retrieve instance by identifier or the top most instance, if identifier is not provided
1521 * @param {String|Numeric} [id] - Optional instance identifier
1522 */
1523 static getInstance(id) {
1524 if (id) {
1525 return instances.get(id);
1526 }
1527
1528 const instance = Array.from(instances.values())
1529 .reverse()
1530 .find((instance) => {
1531 if (!["closing", "customClosing", "destroy"].includes(instance.state)) {
1532 return instance;
1533 }
1534
1535 return false;
1536 });
1537
1538 return instance || null;
1539 }
1540
1541 /**
1542 * Close all or topmost currently active instance.
1543 * @param {boolean} [all] - All or only topmost active instance
1544 * @param {any} [arguments] - Optional data
1545 */
1546 static close(all = true, args) {
1547 if (all) {
1548 for (const instance of instances.values()) {
1549 instance.close(args);
1550 }
1551 } else {
1552 const instance = Fancybox.getInstance();
1553
1554 if (instance) {
1555 instance.close(args);
1556 }
1557 }
1558 }
1559
1560 /**
1561 * Slide topmost currently active instance to next page
1562 */
1563 static next() {
1564 const instance = Fancybox.getInstance();
1565
1566 if (instance) {
1567 instance.next();
1568 }
1569 }
1570
1571 /**
1572 * Slide topmost currently active instance to previous page
1573 */
1574 static prev() {
1575 const instance = Fancybox.getInstance();
1576
1577 if (instance) {
1578 instance.prev();
1579 }
1580 }
1581}
1582
1583// Expose version
1584Fancybox.version = "__VERSION__";
1585
1586// Expose defaults
1587Fancybox.defaults = defaults;
1588
1589// Expose openers
1590Fancybox.openers = new Map();
1591
1592// Add default plugins
1593Fancybox.Plugins = Plugins;
1594
1595// Auto init with default options
1596Fancybox.bind("[data-fancybox]");
1597
1598// Prepare plugins
1599for (const [key, Plugin] of Object.entries(Fancybox.Plugins || {})) {
1600 if (typeof Plugin.create === "function") {
1601 Plugin.create(Fancybox);
1602 }
1603}
1604
1605export { Fancybox };