├── .github └── FUNDING.yml ├── .gitignore ├── .stylelintrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── dist ├── css │ ├── parvus.css │ └── parvus.min.css └── js │ ├── parvus.esm.js │ ├── parvus.esm.min.js │ ├── parvus.js │ └── parvus.min.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── js │ ├── get-focusable-children.js │ ├── get-scrollbar-width.js │ ├── parvus.js │ └── zoom-indicator.js ├── l10n │ ├── de.js │ ├── en.js │ └── nl.js └── scss │ └── parvus.scss └── test ├── images ├── 1-1000.webp ├── 1-1200.webp ├── 1-370.webp ├── 1-500.webp ├── 1-700.webp ├── 2-1000.webp ├── 2-1200.webp ├── 2-370.webp ├── 2-500.webp ├── 2-700.webp ├── 3-1000.webp ├── 3-1200.webp ├── 3-370.webp ├── 3-500.webp ├── 3-700.webp ├── 4-1000.webp ├── 4-1200.webp ├── 4-370.webp ├── 4-500.webp ├── 4-700.webp ├── 8-1000.webp ├── 8-1200.webp ├── 8-370.webp ├── 8-500.webp ├── 8-700.webp ├── 9-1000.webp ├── 9-1200.webp ├── 9-370.webp ├── 9-500.webp └── 9-700.webp ├── test.css ├── test.html └── test.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [deoostfrees] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | node_modules 4 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard-scss" 4 | ], 5 | "plugins": [ 6 | "stylelint-scss", 7 | "stylelint-use-logical" 8 | ], 9 | "rules": { 10 | "at-rule-no-unknown": null, 11 | "scss/at-rule-no-unknown": true, 12 | "color-hex-length": "long", 13 | "comment-whitespace-inside": null, 14 | "no-descending-specificity": null, 15 | "shorthand-property-no-redundant-values": [true, {"severity": "warning"}], 16 | "declaration-no-important": true, 17 | "no-duplicate-at-import-rules": true, 18 | "selector-max-id": 0, 19 | "declaration-block-no-duplicate-properties": true, 20 | "rule-empty-line-before": ["always-multi-line", {"ignore": ["after-comment"]}], 21 | "value-keyword-case": "lower", 22 | "scss/at-import-partial-extension": null, 23 | "selector-class-pattern": ["^([a-z][a-z0-9]*)(-[a-z0-9]+)*(_[a-z0-9]+)*(__[a-z]((_|-)?[a-z0-9])*)?(--[a-z0-9]((_|-)?[a-z0-9\\\\\\/])*)?$", { "resolveNestedSelectors": true }], 24 | "declaration-block-no-redundant-longhand-properties": null, 25 | "csstools/use-logical": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [3.0.0] - 2025-03-16 4 | 5 | ### Added 6 | 7 | - Pinch zoom gestures 4a591e7 4a8355a fd4ebf1 4e472ef 49c5b16 d27efd9 @deoostfrees #42 8 | - Option to make the zoom indicator optional e65d5c7 @deoostfrees #62 9 | 10 | ### Changed 11 | 12 | - Use the native HTML `dialog` element e703293 @deoostfrees #60 13 | - Use the View Transitions API for the zoom in/ out animation 11e183f @deoostfrees 14 | - Use pointer events instead of mouse and touch events b4941cf @deoostfrees 15 | 16 | ### Removed 17 | 18 | - **Breaking:** The custom event `detail` property 4ea8e38 @deoostfrees 19 | - The `transitionDuration` option. This option is now also set via the available CSS custom property 11e183f @deoostfrees 20 | - The `transitionTimingFunction` option. This option is now also set via the available CSS custom property 11e183f @deoostfrees 21 | - The `loadEmpty` option. The internal `add` function now creates the lightbox 98e41b5 @deoostfrees 22 | - The custom `close` event. The native HTML `dialog` element has its own `close` event dba4678 @deoostfrees 23 | 24 | ## [2.6.0] - 2024-06-05 25 | 26 | ### Changed 27 | 28 | - Run `change` event listener for `reducedMotionCheck` only when the lightbox is open 083a0e7 @deoostfrees 29 | 30 | ### Fixed 31 | 32 | - Avoid unintentionally moving the image when dragging 96ff56e @deoostfrees #59 33 | - Relationship between caption and image 76df207 @deoostfrees 34 | 35 | ## [2.5.3] - 2024-04-27 36 | 37 | ### Fixed 38 | 39 | - Remove optional files field in package.json to include all files via NPM 819e132 @deoostfrees 40 | 41 | ## [2.5.2] - 2024-04-27 42 | 43 | ### Fixed 44 | 45 | - Language file import afe86dc @deoostfrees #55 46 | 47 | ## [2.5.1] - 2024-04-10 48 | 49 | ### Fixed 50 | 51 | - Issue if no language options are set 2dbed4a @deoostfrees 52 | 53 | ## [2.5.0] - 2024-04-07 54 | 55 | ### Added 56 | 57 | - Option to load an empty lightbox (even if there are no elements) 9a180fc @deoostfrees a436a81 @drhino 58 | - Fallback to the default language 39e1ae0 @drhino 59 | - Dutch translation 7476426 @drhino 60 | 61 | ### Changed 62 | 63 | - **Breaking:** Rename some CSS custom properties 8b43c66 8ba1f00 @deoostfrees 64 | 65 | ### Removed 66 | 67 | - Slide animation when first/ last slide is visible 4df766b @deoostfrees #52 68 | 69 | ## [2.4.0] - 2023-07-20 70 | 71 | ### Added 72 | 73 | - Option to hide the browser scrollbar #47 74 | 75 | ### Changed 76 | 77 | - Added an internal function to create and dispatch a new event 78 | - Disabled buttons are no longer visually hidden 79 | - Focus is no longer moved automatically 80 | - CSS styles are now moved from SVG to the actual elements 81 | 82 | ### Removed 83 | 84 | - Custom typography styles 85 | 86 | ### Fixed 87 | 88 | - Load the srcset before the src, add sizes attribute #49 89 | 90 | ## [2.3.3] - 2023-05-30 91 | 92 | ### Fixed 93 | 94 | - Animate current image and set focus back to the correct element in the default behavior of the `backFocus` option 95 | 96 | ## [2.3.2] - 2023-05-30 97 | 98 | ### Fixed 99 | 100 | - Set focus back to the correct element in the default behavior of the `backFocus` option 101 | 102 | ## [2.3.1] - 2023-05-29 103 | 104 | ### Fixed 105 | 106 | - The navigation buttons' visibility 107 | 108 | ## [2.3.0] - 2023-05-27 109 | 110 | ### Added 111 | 112 | - Changelog section to keep track of changes 113 | - Necessary outputs for screen reader support 114 | - CSS custom properties for captions and image loading error messages 115 | 116 | ### Changed 117 | 118 | - Replaced the custom `copyObject()` function with the built-in `structuredClone()` method 119 | - Refactored code and comments to improve readability and optimize performance 120 | 121 | ### Removed 122 | 123 | - The option for supported image file types as it is no longer necessary 124 | - The `scrollClose` option 125 | 126 | ### Fixed 127 | 128 | - Non standard URLs can break Parvus #43 129 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2020-2025 Benjamin de Oostfrees 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Parvus 2 | 3 | Overlays suck, but if you need one, consider using Parvus. Parvus is an open source, dependency free image lightbox with the goal of being accessible. 4 | 5 |  6 | 7 | [Open in CodePen](https://codepen.io/collection/DwLBpz) 8 | 9 | ## Table of Contents 10 | 11 | - [Installation](#installation) 12 | - [Download](#download) 13 | - [Package Managers](#package-managers) 14 | - [Usage](#usage) 15 | - [Captions](#captions) 16 | - [Gallery](#gallery) 17 | - [Responsive Images](#responsive-images) 18 | - [Localization](#localization) 19 | - [Options](#options) 20 | - [API](#api) 21 | - [Events](#events) 22 | - [Browser Support](#browser-support) 23 | 24 | ## Installation 25 | 26 | ### Download 27 | 28 | - CSS: 29 | - `dist/css/parvus.min.css` (minified) or 30 | - `dist/css/parvus.css` (un-minified) 31 | - JavaScript: 32 | - `dist/js/parvus.min.js` (minified) or 33 | - `dist/js/parvus.js` (un-minified) 34 | 35 | Link the `.css` and `.js` files in your HTML: 36 | 37 | ```html 38 | 39 | 40 |
41 | 42 | 43 |I'm a caption
114 |${captionData}
`; 641 | containerEl.appendChild(CAPTION_CONTAINER); 642 | imageEl.setAttribute('aria-describedby', CAPTION_ID); 643 | } 644 | }; 645 | const createImage = (el, index, callback) => { 646 | const { 647 | contentElements, 648 | sliderElements 649 | } = GROUPS[activeGroup]; 650 | if (contentElements[index] !== undefined) { 651 | if (callback && typeof callback === 'function') { 652 | callback(); 653 | } 654 | return; 655 | } 656 | const CONTENT_CONTAINER_EL = sliderElements[index].querySelector('div'); 657 | const IMAGE = new Image(); 658 | const IMAGE_CONTAINER = document.createElement('div'); 659 | const THUMBNAIL = el.querySelector('img'); 660 | const LOADING_INDICATOR = document.createElement('div'); 661 | IMAGE_CONTAINER.className = 'parvus__content'; 662 | 663 | // Create loading indicator 664 | LOADING_INDICATOR.className = 'parvus__loader'; 665 | LOADING_INDICATOR.setAttribute('role', 'progressbar'); 666 | LOADING_INDICATOR.setAttribute('aria-label', config.l10n.lightboxLoadingIndicatorLabel); 667 | 668 | // Add loading indicator to content container 669 | CONTENT_CONTAINER_EL.appendChild(LOADING_INDICATOR); 670 | const checkImagePromise = new Promise((resolve, reject) => { 671 | IMAGE.onload = () => resolve(IMAGE); 672 | IMAGE.onerror = error => reject(error); 673 | }); 674 | checkImagePromise.then(loadedImage => { 675 | loadedImage.style.opacity = 0; 676 | IMAGE_CONTAINER.appendChild(loadedImage); 677 | CONTENT_CONTAINER_EL.appendChild(IMAGE_CONTAINER); 678 | 679 | // Add caption if available 680 | if (config.captions) { 681 | addCaption(CONTENT_CONTAINER_EL, IMAGE, el, index); 682 | } 683 | contentElements[index] = loadedImage; 684 | 685 | // Set image width and height 686 | loadedImage.setAttribute('width', loadedImage.naturalWidth); 687 | loadedImage.setAttribute('height', loadedImage.naturalHeight); 688 | 689 | // Set image dimension 690 | setImageDimension(sliderElements[index], loadedImage); 691 | }).catch(() => { 692 | const ERROR_CONTAINER = document.createElement('div'); 693 | ERROR_CONTAINER.classList.add('parvus__content'); 694 | ERROR_CONTAINER.classList.add('parvus__content--error'); 695 | ERROR_CONTAINER.textContent = config.l10n.lightboxLoadingError; 696 | CONTENT_CONTAINER_EL.appendChild(ERROR_CONTAINER); 697 | contentElements[index] = ERROR_CONTAINER; 698 | }).finally(() => { 699 | CONTENT_CONTAINER_EL.removeChild(LOADING_INDICATOR); 700 | if (callback && typeof callback === 'function') { 701 | callback(); 702 | } 703 | }); 704 | 705 | // Add `sizes` attribute 706 | if (el.hasAttribute('data-sizes') && el.getAttribute('data-sizes') !== '') { 707 | IMAGE.setAttribute('sizes', el.getAttribute('data-sizes')); 708 | } 709 | 710 | // Add `srcset` attribute 711 | if (el.hasAttribute('data-srcset') && el.getAttribute('data-srcset') !== '') { 712 | IMAGE.setAttribute('srcset', el.getAttribute('data-srcset')); 713 | } 714 | 715 | // Add `src` attribute 716 | if (el.tagName === 'A') { 717 | IMAGE.setAttribute('src', el.href); 718 | } else { 719 | IMAGE.setAttribute('src', el.getAttribute('data-target')); 720 | } 721 | 722 | // `alt` attribute 723 | if (THUMBNAIL && THUMBNAIL.hasAttribute('alt') && THUMBNAIL.getAttribute('alt') !== '') { 724 | IMAGE.alt = THUMBNAIL.alt; 725 | } else if (el.hasAttribute('data-alt') && el.getAttribute('data-alt') !== '') { 726 | IMAGE.alt = el.getAttribute('data-alt'); 727 | } else { 728 | IMAGE.alt = ''; 729 | } 730 | }; 731 | 732 | /** 733 | * Load Image 734 | * 735 | * @param {Number} index - The index of the image to load 736 | */ 737 | const loadImage = (index, animate) => { 738 | const IMAGE = GROUPS[activeGroup].contentElements[index]; 739 | if (IMAGE && IMAGE.tagName === 'IMG') { 740 | const THUMBNAIL = GROUPS[activeGroup].triggerElements[index]; 741 | if (animate && document.startViewTransition) { 742 | THUMBNAIL.style.viewTransitionName = 'lightboximage'; 743 | const transition = document.startViewTransition(() => { 744 | IMAGE.style.opacity = ''; 745 | THUMBNAIL.style.viewTransitionName = null; 746 | IMAGE.style.viewTransitionName = 'lightboximage'; 747 | }); 748 | transition.finished.finally(() => { 749 | IMAGE.style.viewTransitionName = null; 750 | }); 751 | } else { 752 | IMAGE.style.opacity = ''; 753 | } 754 | } else { 755 | IMAGE.style.opacity = ''; 756 | } 757 | }; 758 | 759 | /** 760 | * Select a specific slide by index 761 | * 762 | * @param {number} index - Index of the slide to select 763 | */ 764 | const select = index => { 765 | if (!isOpen()) { 766 | throw new Error("Oops, I'm closed."); 767 | } 768 | if (typeof index !== 'number' || isNaN(index)) { 769 | throw new Error('Oops, no slide specified.'); 770 | } 771 | const GROUP = GROUPS[activeGroup]; 772 | const triggerElements = GROUP.triggerElements; 773 | if (index === currentIndex) { 774 | throw new Error(`Oops, slide ${index} is already selected.`); 775 | } 776 | if (index < 0 || index >= triggerElements.length) { 777 | throw new Error(`Oops, I can't find slide ${index}.`); 778 | } 779 | const OLD_INDEX = currentIndex; 780 | currentIndex = index; 781 | if (GROUP.sliderElements[index]) { 782 | loadSlide(index); 783 | } else { 784 | createSlide(index); 785 | createImage(GROUP.triggerElements[index], index, () => { 786 | loadImage(index); 787 | }); 788 | loadSlide(index); 789 | } 790 | updateOffset(); 791 | updateSliderNavigationStatus(); 792 | updateCounter(); 793 | if (index < OLD_INDEX) { 794 | preload(index - 1); 795 | } else { 796 | preload(index + 1); 797 | } 798 | leaveSlide(OLD_INDEX); 799 | 800 | // Create and dispatch a new event 801 | dispatchCustomEvent('select'); 802 | }; 803 | 804 | /** 805 | * Select the previous slide 806 | * 807 | */ 808 | const previous = () => { 809 | if (currentIndex > 0) { 810 | select(currentIndex - 1); 811 | } 812 | }; 813 | 814 | /** 815 | * Select the next slide 816 | * 817 | */ 818 | const next = () => { 819 | const { 820 | triggerElements 821 | } = GROUPS[activeGroup]; 822 | if (currentIndex < triggerElements.length - 1) { 823 | select(currentIndex + 1); 824 | } 825 | }; 826 | 827 | /** 828 | * Leave slide 829 | * 830 | * This function is called after moving the index to a new slide. 831 | * 832 | * @param {Number} index - The index of the slide to leave. 833 | */ 834 | const leaveSlide = index => { 835 | if (GROUPS[activeGroup].sliderElements[index] !== undefined) { 836 | GROUPS[activeGroup].sliderElements[index].setAttribute('aria-hidden', 'true'); 837 | } 838 | }; 839 | 840 | /** 841 | * Update offset 842 | * 843 | */ 844 | const updateOffset = () => { 845 | activeGroup = activeGroup !== null ? activeGroup : newGroup; 846 | offset = -currentIndex * lightbox.offsetWidth; 847 | GROUPS[activeGroup].slider.style.transform = `translate3d(${offset}px, 0, 0)`; 848 | offsetTmp = offset; 849 | }; 850 | 851 | /** 852 | * Update slider navigation status 853 | * 854 | * This function updates the disabled status of the slider navigation buttons 855 | * based on the current slide position. 856 | * 857 | */ 858 | const updateSliderNavigationStatus = () => { 859 | const { 860 | triggerElements 861 | } = GROUPS[activeGroup]; 862 | const TOTAL_TRIGGER_ELEMENTS = triggerElements.length; 863 | if (TOTAL_TRIGGER_ELEMENTS <= 1) { 864 | return; 865 | } 866 | 867 | // Determine navigation state 868 | const FIRST_SLIDE = currentIndex === 0; 869 | const LAST_SLIDE = currentIndex === TOTAL_TRIGGER_ELEMENTS - 1; 870 | 871 | // Set previous button state 872 | const PREV_DISABLED = FIRST_SLIDE ? 'true' : null; 873 | if (previousButton.getAttribute('aria-disabled') === 'true' !== !!PREV_DISABLED) { 874 | PREV_DISABLED ? previousButton.setAttribute('aria-disabled', 'true') : previousButton.removeAttribute('aria-disabled'); 875 | } 876 | 877 | // Set next button state 878 | const NEXT_DISABLED = LAST_SLIDE ? 'true' : null; 879 | if (nextButton.getAttribute('aria-disabled') === 'true' !== !!NEXT_DISABLED) { 880 | NEXT_DISABLED ? nextButton.setAttribute('aria-disabled', 'true') : nextButton.removeAttribute('aria-disabled'); 881 | } 882 | }; 883 | 884 | /** 885 | * Update counter 886 | * 887 | * This function updates the counter display based on the current slide index. 888 | */ 889 | const updateCounter = () => { 890 | counter.textContent = `${currentIndex + 1}/${GROUPS[activeGroup].triggerElements.length}`; 891 | }; 892 | 893 | /** 894 | * Clear drag after pointerup event 895 | * 896 | * This function clears the drag state after the pointerup event is triggered. 897 | */ 898 | const clearDrag = () => { 899 | drag = { 900 | startX: 0, 901 | endX: 0, 902 | startY: 0, 903 | endY: 0 904 | }; 905 | }; 906 | 907 | /** 908 | * Recalculate drag/swipe event 909 | * 910 | */ 911 | const updateAfterDrag = () => { 912 | const { 913 | startX, 914 | startY, 915 | endX, 916 | endY 917 | } = drag; 918 | const MOVEMENT_X = endX - startX; 919 | const MOVEMENT_Y = endY - startY; 920 | const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X); 921 | const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y); 922 | const { 923 | triggerElements 924 | } = GROUPS[activeGroup]; 925 | const TOTAL_TRIGGER_ELEMENTS = triggerElements.length; 926 | if (isDraggingX) { 927 | const IS_RIGHT_SWIPE = MOVEMENT_X > 0; 928 | if (MOVEMENT_X_DISTANCE >= config.threshold) { 929 | if (IS_RIGHT_SWIPE && currentIndex > 0) { 930 | previous(); 931 | } else if (!IS_RIGHT_SWIPE && currentIndex < TOTAL_TRIGGER_ELEMENTS - 1) { 932 | next(); 933 | } 934 | } 935 | updateOffset(); 936 | } else if (isDraggingY) { 937 | if (MOVEMENT_Y_DISTANCE >= config.threshold && config.swipeClose) { 938 | close(); 939 | } else { 940 | lightbox.classList.remove('parvus--is-vertical-closing'); 941 | updateOffset(); 942 | } 943 | lightboxOverlay.style.opacity = ''; 944 | } else { 945 | updateOffset(); 946 | } 947 | }; 948 | 949 | /** 950 | * Update Attributes 951 | * 952 | */ 953 | const updateAttributes = () => { 954 | const TRIGGER_ELEMENTS = GROUPS[activeGroup].triggerElements; 955 | const TOTAL_TRIGGER_ELEMENTS = TRIGGER_ELEMENTS.length; 956 | const SLIDER = GROUPS[activeGroup].slider; 957 | const SLIDER_ELEMENTS = GROUPS[activeGroup].sliderElements; 958 | const IS_DRAGGABLE = SLIDER.classList.contains('parvus__slider--is-draggable'); 959 | 960 | // Add draggable class if neccesary 961 | if (config.simulateTouch && config.swipeClose && !IS_DRAGGABLE || config.simulateTouch && TOTAL_TRIGGER_ELEMENTS > 1 && !IS_DRAGGABLE) { 962 | SLIDER.classList.add('parvus__slider--is-draggable'); 963 | } else { 964 | SLIDER.classList.remove('parvus__slider--is-draggable'); 965 | } 966 | 967 | // Add extra output for screen reader if there is more than one slide 968 | if (TOTAL_TRIGGER_ELEMENTS > 1) { 969 | SLIDER.setAttribute('role', 'region'); 970 | SLIDER.setAttribute('aria-roledescription', 'carousel'); 971 | SLIDER.setAttribute('aria-label', config.l10n.sliderLabel); 972 | SLIDER_ELEMENTS.forEach((sliderElement, index) => { 973 | sliderElement.setAttribute('role', 'group'); 974 | sliderElement.setAttribute('aria-label', `${config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`); 975 | }); 976 | } else { 977 | SLIDER.removeAttribute('role'); 978 | SLIDER.removeAttribute('aria-roledescription'); 979 | SLIDER.removeAttribute('aria-label'); 980 | SLIDER_ELEMENTS.forEach(sliderElement => { 981 | sliderElement.removeAttribute('role'); 982 | sliderElement.removeAttribute('aria-label'); 983 | }); 984 | } 985 | 986 | // Show or hide buttons 987 | if (TOTAL_TRIGGER_ELEMENTS === 1) { 988 | counter.setAttribute('aria-hidden', 'true'); 989 | previousButton.setAttribute('aria-hidden', 'true'); 990 | nextButton.setAttribute('aria-hidden', 'true'); 991 | } else { 992 | counter.removeAttribute('aria-hidden'); 993 | previousButton.removeAttribute('aria-hidden'); 994 | nextButton.removeAttribute('aria-hidden'); 995 | } 996 | }; 997 | 998 | /** 999 | * Resize event handler 1000 | * 1001 | */ 1002 | const resizeHandler = () => { 1003 | if (!resizeTicking) { 1004 | resizeTicking = true; 1005 | BROWSER_WINDOW.requestAnimationFrame(() => { 1006 | GROUPS[activeGroup].sliderElements.forEach((slide, index) => { 1007 | setImageDimension(slide, GROUPS[activeGroup].contentElements[index]); 1008 | }); 1009 | updateOffset(); 1010 | resizeTicking = false; 1011 | }); 1012 | } 1013 | }; 1014 | 1015 | /** 1016 | * Set image dimension 1017 | * 1018 | * @param {HTMLElement} slideEl - The slide element 1019 | * @param {HTMLElement} contentEl - The content element 1020 | */ 1021 | const setImageDimension = (slideEl, contentEl) => { 1022 | if (contentEl.tagName !== 'IMG') { 1023 | return; 1024 | } 1025 | const SRC_HEIGHT = contentEl.getAttribute('height'); 1026 | const SRC_WIDTH = contentEl.getAttribute('width'); 1027 | if (!SRC_HEIGHT || !SRC_WIDTH) { 1028 | return; 1029 | } 1030 | const SLIDE_EL_STYLES = getComputedStyle(slideEl); 1031 | const HORIZONTAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingLeft) + parseFloat(SLIDE_EL_STYLES.paddingRight); 1032 | const VERTICAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingTop) + parseFloat(SLIDE_EL_STYLES.paddingBottom); 1033 | const CAPTION_EL = slideEl.querySelector('.parvus__caption'); 1034 | const CAPTION_HEIGHT = CAPTION_EL ? CAPTION_EL.getBoundingClientRect().height : 0; 1035 | const MAX_WIDTH = slideEl.offsetWidth - HORIZONTAL_PADDING; 1036 | const MAX_HEIGHT = slideEl.offsetHeight - VERTICAL_PADDING - CAPTION_HEIGHT; 1037 | const RATIO = Math.min(MAX_WIDTH / SRC_WIDTH || 0, MAX_HEIGHT / SRC_HEIGHT || 0); 1038 | const NEW_WIDTH = SRC_WIDTH * RATIO; 1039 | const NEW_HEIGHT = SRC_HEIGHT * RATIO; 1040 | const USE_ORIGINAL_SIZE = SRC_WIDTH <= MAX_WIDTH && SRC_HEIGHT <= MAX_HEIGHT; 1041 | contentEl.style.width = USE_ORIGINAL_SIZE ? '' : `${NEW_WIDTH}px`; 1042 | contentEl.style.height = USE_ORIGINAL_SIZE ? '' : `${NEW_HEIGHT}px`; 1043 | }; 1044 | 1045 | /** 1046 | * Reset image zoom 1047 | * 1048 | * @param {HTMLImageElement} currentImg - The image 1049 | */ 1050 | const resetZoom = currentImg => { 1051 | currentImg.style.transition = 'transform 0.3s ease'; 1052 | currentImg.style.transform = ''; 1053 | setTimeout(() => { 1054 | currentImg.style.transition = ''; 1055 | currentImg.style.transformOrigin = ''; 1056 | }, 300); 1057 | isPinching = false; 1058 | isTap = false; 1059 | currentScale = 1; 1060 | pinchStartDistance = 0; 1061 | lastPointersId = ''; 1062 | lightbox.classList.remove('parvus--is-zooming'); 1063 | }; 1064 | 1065 | /** 1066 | * Pinch zoom gesture 1067 | * 1068 | * @param {HTMLImageElement} currentImg - The image to zoom 1069 | */ 1070 | const pinchZoom = currentImg => { 1071 | // Determine current finger positions 1072 | const POINTS = Array.from(activePointers.values()); 1073 | 1074 | // Calculate current distance between fingers 1075 | const CURRENT_DISTANCE = Math.hypot(POINTS[1].clientX - POINTS[0].clientX, POINTS[1].clientY - POINTS[0].clientY); 1076 | 1077 | // Calculate the midpoint between the two points 1078 | const MIDPOINT_X = (POINTS[0].clientX + POINTS[1].clientX) / 2; 1079 | const MIDPOINT_Y = (POINTS[0].clientY + POINTS[1].clientY) / 2; 1080 | 1081 | // Convert midpoint to relative position within the image 1082 | const IMG_RECT = currentImg.getBoundingClientRect(); 1083 | const RELATIVE_X = (MIDPOINT_X - IMG_RECT.left) / IMG_RECT.width; 1084 | const RELATIVE_Y = (MIDPOINT_Y - IMG_RECT.top) / IMG_RECT.height; 1085 | 1086 | // When pinch gesture is about to start or the finger IDs have changed 1087 | // Use a unique ID based on the pointer IDs to recognize changes 1088 | const CURRENT_POINTERS_ID = POINTS.map(p => p.pointerId).sort().join('-'); 1089 | const IS_NEW_POINTER_COMBINATION = lastPointersId !== CURRENT_POINTERS_ID; 1090 | if (!isPinching || IS_NEW_POINTER_COMBINATION) { 1091 | isPinching = true; 1092 | lastPointersId = CURRENT_POINTERS_ID; 1093 | 1094 | // Save the start distance and current scaling as a basis 1095 | pinchStartDistance = CURRENT_DISTANCE / currentScale; 1096 | 1097 | // Store initial pinch position for this gesture 1098 | if (!currentImg.style.transformOrigin && currentScale === 1 || currentScale === 1 && IS_NEW_POINTER_COMBINATION) { 1099 | // Set the transform origin to the pinch midpoint 1100 | currentImg.style.transformOrigin = `${RELATIVE_X * 100}% ${RELATIVE_Y * 100}%`; 1101 | } 1102 | lightbox.classList.add('parvus--is-zooming'); 1103 | } 1104 | 1105 | // Calculate scaling factor based on distance change 1106 | const SCALE_FACTOR = CURRENT_DISTANCE / pinchStartDistance; 1107 | 1108 | // Limit scaling to 1 - 3 1109 | currentScale = Math.min(Math.max(1, SCALE_FACTOR), 3); 1110 | currentImg.style.willChange = 'transform'; 1111 | currentImg.style.transform = `scale(${currentScale})`; 1112 | }; 1113 | 1114 | /** 1115 | * Click event handler to trigger Parvus 1116 | * 1117 | * @param {Event} event - The click event object 1118 | */ 1119 | const triggerParvus = function triggerParvus(event) { 1120 | event.preventDefault(); 1121 | open(this); 1122 | }; 1123 | 1124 | /** 1125 | * Event handler for click events 1126 | * 1127 | * @param {Event} event - The click event object 1128 | */ 1129 | const clickHandler = event => { 1130 | const { 1131 | target 1132 | } = event; 1133 | if (target === previousButton) { 1134 | previous(); 1135 | } else if (target === nextButton) { 1136 | next(); 1137 | } else if (target === closeButton || config.docClose && !isDraggingY && !isDraggingX && target.classList.contains('parvus__slide')) { 1138 | close(); 1139 | } 1140 | event.stopPropagation(); 1141 | }; 1142 | 1143 | /** 1144 | * Event handler for the keydown event 1145 | * 1146 | * @param {Event} event - The keydown event object 1147 | */ 1148 | const keydownHandler = event => { 1149 | const FOCUSABLE_CHILDREN = getFocusableChildren(lightbox); 1150 | const FOCUSED_ITEM_INDEX = FOCUSABLE_CHILDREN.indexOf(document.activeElement); 1151 | const lastIndex = FOCUSABLE_CHILDREN.length - 1; 1152 | switch (event.code) { 1153 | case 'Tab': 1154 | { 1155 | // Use the TAB key to navigate backwards and forwards 1156 | if (event.shiftKey) { 1157 | // Navigate backwards 1158 | if (FOCUSED_ITEM_INDEX === 0) { 1159 | FOCUSABLE_CHILDREN[lastIndex].focus(); 1160 | event.preventDefault(); 1161 | } 1162 | } else { 1163 | // Navigate forwards 1164 | if (FOCUSED_ITEM_INDEX === lastIndex) { 1165 | FOCUSABLE_CHILDREN[0].focus(); 1166 | event.preventDefault(); 1167 | } 1168 | } 1169 | break; 1170 | } 1171 | case 'Escape': 1172 | { 1173 | // Close Parvus when the ESC key is pressed 1174 | close(); 1175 | event.preventDefault(); 1176 | break; 1177 | } 1178 | case 'ArrowLeft': 1179 | { 1180 | // Show the previous slide when the PREV key is pressed 1181 | previous(); 1182 | event.preventDefault(); 1183 | break; 1184 | } 1185 | case 'ArrowRight': 1186 | { 1187 | // Show the next slide when the NEXT key is pressed 1188 | next(); 1189 | event.preventDefault(); 1190 | break; 1191 | } 1192 | } 1193 | }; 1194 | 1195 | /** 1196 | * Event handler for the pointerdown event. 1197 | * 1198 | * This function is triggered when a pointer becomes active buttons state. 1199 | * It handles the necessary actions and logic related to the pointerdown event. 1200 | * 1201 | * @param {Event} event - The pointerdown event object 1202 | */ 1203 | const pointerdownHandler = event => { 1204 | event.preventDefault(); 1205 | event.stopPropagation(); 1206 | isDraggingX = false; 1207 | isDraggingY = false; 1208 | pointerDown = true; 1209 | activePointers.set(event.pointerId, event); 1210 | drag.startX = event.pageX; 1211 | drag.startY = event.pageY; 1212 | drag.endX = event.pageX; 1213 | drag.endY = event.pageY; 1214 | const { 1215 | slider 1216 | } = GROUPS[activeGroup]; 1217 | slider.classList.add('parvus__slider--is-dragging'); 1218 | slider.style.willChange = 'transform'; 1219 | isTap = activePointers.size === 1; 1220 | if (config.swipeClose) { 1221 | lightboxOverlayOpacity = getComputedStyle(lightboxOverlay).opacity; 1222 | } 1223 | }; 1224 | 1225 | /** 1226 | * Event handler for the pointermove event. 1227 | * 1228 | * This function is triggered when a pointer changes coordinates. 1229 | * It handles the necessary actions and logic related to the pointermove event. 1230 | * 1231 | * @param {Event} event - The pointermove event object 1232 | */ 1233 | const pointermoveHandler = event => { 1234 | event.preventDefault(); 1235 | if (!pointerDown) { 1236 | return; 1237 | } 1238 | const CURRENT_IMAGE = GROUPS[activeGroup].contentElements[currentIndex]; 1239 | 1240 | // Update pointer position 1241 | activePointers.set(event.pointerId, event); 1242 | 1243 | // Zoom 1244 | if (CURRENT_IMAGE && CURRENT_IMAGE.tagName === 'IMG') { 1245 | if (activePointers.size === 2) { 1246 | pinchZoom(CURRENT_IMAGE); 1247 | return; 1248 | } 1249 | if (currentScale > 1) { 1250 | return; 1251 | } 1252 | } 1253 | drag.endX = event.pageX; 1254 | drag.endY = event.pageY; 1255 | doSwipe(); 1256 | }; 1257 | 1258 | /** 1259 | * Event handler for the pointerup event. 1260 | * 1261 | * This function is triggered when a pointer is no longer active buttons state. 1262 | * It handles the necessary actions and logic related to the pointerup event. 1263 | * 1264 | * @param {Event} event - The pointerup event object 1265 | */ 1266 | const pointerupHandler = event => { 1267 | event.stopPropagation(); 1268 | const { 1269 | slider 1270 | } = GROUPS[activeGroup]; 1271 | activePointers.delete(event.pointerId); 1272 | if (activePointers.size > 0) { 1273 | return; 1274 | } 1275 | pointerDown = false; 1276 | const CURRENT_IMAGE = GROUPS[activeGroup].contentElements[currentIndex]; 1277 | 1278 | // Reset zoom state by one tap 1279 | const MOVEMENT_X = Math.abs(drag.endX - drag.startX); 1280 | const MOVEMENT_Y = Math.abs(drag.endY - drag.startY); 1281 | const IS_TAP = MOVEMENT_X < 8 && MOVEMENT_Y < 8 && !isDraggingX && !isDraggingY && isTap; 1282 | slider.classList.remove('parvus__slider--is-dragging'); 1283 | slider.style.willChange = ''; 1284 | if (currentScale > 1) { 1285 | if (IS_TAP) { 1286 | resetZoom(CURRENT_IMAGE); 1287 | } else { 1288 | CURRENT_IMAGE.style.transform = ` 1289 | scale(${currentScale}) 1290 | `; 1291 | } 1292 | } else { 1293 | if (isPinching) { 1294 | resetZoom(CURRENT_IMAGE); 1295 | } 1296 | if (drag.endX || drag.endY) { 1297 | updateAfterDrag(); 1298 | } 1299 | } 1300 | clearDrag(); 1301 | }; 1302 | 1303 | /** 1304 | * Determine the swipe direction (horizontal or vertical). 1305 | * 1306 | * This function analyzes the swipe gesture and decides whether it is a horizontal 1307 | * or vertical swipe based on the direction and angle of the swipe. 1308 | */ 1309 | const doSwipe = () => { 1310 | const MOVEMENT_THRESHOLD = 1.5; 1311 | const MAX_OPACITY_DISTANCE = 100; 1312 | const DIRECTION_BIAS = 1.15; 1313 | const { 1314 | startX, 1315 | endX, 1316 | startY, 1317 | endY 1318 | } = drag; 1319 | const MOVEMENT_X = startX - endX; 1320 | const MOVEMENT_Y = endY - startY; 1321 | const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X); 1322 | const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y); 1323 | const GROUP = GROUPS[activeGroup]; 1324 | const SLIDER = GROUP.slider; 1325 | const TOTAL_SLIDES = GROUP.triggerElements.length; 1326 | const handleHorizontalSwipe = (movementX, distance) => { 1327 | const IS_FIRST_SLIDE = currentIndex === 0; 1328 | const IS_LAST_SLIDE = currentIndex === TOTAL_SLIDES - 1; 1329 | const IS_LEFT_SWIPE = movementX > 0; 1330 | const IS_RIGHT_SWIPE = movementX < 0; 1331 | if (IS_FIRST_SLIDE && IS_RIGHT_SWIPE || IS_LAST_SLIDE && IS_LEFT_SWIPE) { 1332 | const DAMPING_FACTOR = 1 / (1 + Math.pow(distance / 100, 0.15)); 1333 | const REDUCED_MOVEMENT = movementX * DAMPING_FACTOR; 1334 | SLIDER.style.transform = ` 1335 | translate3d(${offsetTmp - Math.round(REDUCED_MOVEMENT)}px, 0, 0) 1336 | `; 1337 | } else { 1338 | SLIDER.style.transform = ` 1339 | translate3d(${offsetTmp - Math.round(movementX)}px, 0, 0) 1340 | `; 1341 | } 1342 | }; 1343 | const handleVerticalSwipe = (movementY, distance) => { 1344 | if (!isReducedMotion && distance <= 100) { 1345 | const NEW_OVERLAY_OPACITY = Math.max(0, lightboxOverlayOpacity - distance / MAX_OPACITY_DISTANCE); 1346 | lightboxOverlay.style.opacity = NEW_OVERLAY_OPACITY; 1347 | } 1348 | lightbox.classList.add('parvus--is-vertical-closing'); 1349 | SLIDER.style.transform = ` 1350 | translate3d(${offsetTmp}px, ${Math.round(movementY)}px, 0) 1351 | `; 1352 | }; 1353 | if (isDraggingX || isDraggingY) { 1354 | if (isDraggingX) { 1355 | handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE); 1356 | } else if (isDraggingY) { 1357 | handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE); 1358 | } 1359 | return; 1360 | } 1361 | 1362 | // Direction detection based on the relative ratio of movements 1363 | if (MOVEMENT_X_DISTANCE > MOVEMENT_THRESHOLD || MOVEMENT_Y_DISTANCE > MOVEMENT_THRESHOLD) { 1364 | // Horizontal swipe if X-movement is stronger than Y-movement * DIRECTION_BIAS 1365 | if (MOVEMENT_X_DISTANCE > MOVEMENT_Y_DISTANCE * DIRECTION_BIAS && TOTAL_SLIDES > 1) { 1366 | isDraggingX = true; 1367 | isDraggingY = false; 1368 | handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE); 1369 | } else if (MOVEMENT_Y_DISTANCE > MOVEMENT_X_DISTANCE * DIRECTION_BIAS && config.swipeClose) { 1370 | // Vertical swipe if Y-movement is stronger than X-movement * DIRECTION_BIAS 1371 | isDraggingX = false; 1372 | isDraggingY = true; 1373 | handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE); 1374 | } 1375 | } 1376 | }; 1377 | 1378 | /** 1379 | * Bind specified events 1380 | * 1381 | */ 1382 | const bindEvents = () => { 1383 | BROWSER_WINDOW.addEventListener('keydown', keydownHandler); 1384 | BROWSER_WINDOW.addEventListener('resize', resizeHandler); 1385 | 1386 | // Popstate event 1387 | BROWSER_WINDOW.addEventListener('popstate', close); 1388 | 1389 | // Check for any OS level changes to the prefers reduced motion preference 1390 | MOTIONQUERY.addEventListener('change', reducedMotionCheck); 1391 | 1392 | // Click event 1393 | lightbox.addEventListener('click', clickHandler); 1394 | 1395 | // Pointer events 1396 | lightbox.addEventListener('pointerdown', pointerdownHandler, { 1397 | passive: false 1398 | }); 1399 | lightbox.addEventListener('pointerup', pointerupHandler, { 1400 | passive: true 1401 | }); 1402 | lightbox.addEventListener('pointermove', pointermoveHandler, { 1403 | passive: false 1404 | }); 1405 | }; 1406 | 1407 | /** 1408 | * Unbind specified events 1409 | * 1410 | */ 1411 | const unbindEvents = () => { 1412 | BROWSER_WINDOW.removeEventListener('keydown', keydownHandler); 1413 | BROWSER_WINDOW.removeEventListener('resize', resizeHandler); 1414 | 1415 | // Popstate event 1416 | BROWSER_WINDOW.removeEventListener('popstate', close); 1417 | 1418 | // Check for any OS level changes to the prefers reduced motion preference 1419 | MOTIONQUERY.removeEventListener('change', reducedMotionCheck); 1420 | 1421 | // Click event 1422 | lightbox.removeEventListener('click', clickHandler); 1423 | 1424 | // Pointer events 1425 | lightbox.removeEventListener('pointerdown', pointerdownHandler); 1426 | lightbox.removeEventListener('pointerup', pointerupHandler); 1427 | lightbox.removeEventListener('pointermove', pointermoveHandler); 1428 | }; 1429 | 1430 | /** 1431 | * Destroy Parvus 1432 | * 1433 | */ 1434 | const destroy = () => { 1435 | if (!lightbox) { 1436 | return; 1437 | } 1438 | if (isOpen()) { 1439 | close(); 1440 | } 1441 | 1442 | // Add setTimeout to ensure all possible close transitions are completed 1443 | setTimeout(() => { 1444 | unbindEvents(); 1445 | 1446 | // Remove all registered event listeners for custom events 1447 | const eventTypes = ['open', 'close', 'select', 'destroy']; 1448 | eventTypes.forEach(eventType => { 1449 | const listeners = lightbox._listeners?.[eventType] || []; 1450 | listeners.forEach(listener => { 1451 | lightbox.removeEventListener(eventType, listener); 1452 | }); 1453 | }); 1454 | 1455 | // Remove event listeners from trigger elements 1456 | const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll('.parvus-trigger'); 1457 | LIGHTBOX_TRIGGER_ELS.forEach(el => { 1458 | el.removeEventListener('click', triggerParvus); 1459 | el.classList.remove('parvus-trigger'); 1460 | if (config.zoomIndicator) { 1461 | removeZoomIndicator(el); 1462 | } 1463 | if (el.dataset.group) { 1464 | delete el.dataset.group; 1465 | } 1466 | }); 1467 | 1468 | // Create and dispatch a new event 1469 | dispatchCustomEvent('destroy'); 1470 | lightbox.remove(); 1471 | 1472 | // Remove references 1473 | lightbox = null; 1474 | lightboxOverlay = null; 1475 | toolbar = null; 1476 | toolbarLeft = null; 1477 | toolbarRight = null; 1478 | controls = null; 1479 | previousButton = null; 1480 | nextButton = null; 1481 | closeButton = null; 1482 | counter = null; 1483 | 1484 | // Remove group data 1485 | Object.keys(GROUPS).forEach(groupKey => { 1486 | const group = GROUPS[groupKey]; 1487 | if (group && group.contentElements) { 1488 | group.contentElements.forEach(content => { 1489 | if (content && content.tagName === 'IMG') { 1490 | content.src = ''; 1491 | content.srcset = ''; 1492 | } 1493 | }); 1494 | } 1495 | delete GROUPS[groupKey]; 1496 | }); 1497 | 1498 | // Reset variables 1499 | groupIdCounter = 0; 1500 | newGroup = null; 1501 | activeGroup = null; 1502 | currentIndex = 0; 1503 | }, 1000); 1504 | }; 1505 | 1506 | /** 1507 | * Check if Parvus is open 1508 | * 1509 | * @returns {boolean} - True if Parvus is open, otherwise false 1510 | */ 1511 | const isOpen = () => { 1512 | return lightbox.hasAttribute('open'); 1513 | }; 1514 | 1515 | /** 1516 | * Get the current index 1517 | * 1518 | * @returns {number} - The current index 1519 | */ 1520 | const getCurrentIndex = () => { 1521 | return currentIndex; 1522 | }; 1523 | 1524 | /** 1525 | * Dispatch a custom event 1526 | * 1527 | * @param {String} type - The type of the event to dispatch 1528 | */ 1529 | const dispatchCustomEvent = type => { 1530 | const CUSTOM_EVENT = new CustomEvent(type, { 1531 | cancelable: true 1532 | }); 1533 | lightbox.dispatchEvent(CUSTOM_EVENT); 1534 | }; 1535 | 1536 | /** 1537 | * Bind a specific event listener 1538 | * 1539 | * @param {String} eventName - The name of the event to Bind 1540 | * @param {Function} callback - The callback function 1541 | */ 1542 | const on = (eventName, callback) => { 1543 | if (lightbox) { 1544 | lightbox.addEventListener(eventName, callback); 1545 | } 1546 | }; 1547 | 1548 | /** 1549 | * Unbind a specific event listener 1550 | * 1551 | * @param {String} eventName - The name of the event to unbind 1552 | * @param {Function} callback - The callback function 1553 | */ 1554 | const off = (eventName, callback) => { 1555 | if (lightbox) { 1556 | lightbox.removeEventListener(eventName, callback); 1557 | } 1558 | }; 1559 | 1560 | /** 1561 | * Init 1562 | * 1563 | */ 1564 | const init = () => { 1565 | // Merge user options into defaults 1566 | config = mergeOptions(userOptions); 1567 | reducedMotionCheck(); 1568 | if (config.gallerySelector !== null) { 1569 | // Get a list of all `gallerySelector` elements within the document 1570 | const GALLERY_ELS = document.querySelectorAll(config.gallerySelector); 1571 | 1572 | // Execute a few things once per element 1573 | GALLERY_ELS.forEach((galleryEl, index) => { 1574 | const GALLERY_INDEX = index; 1575 | // Get a list of all `selector` elements within the `gallerySelector` 1576 | const LIGHTBOX_TRIGGER_GALLERY_ELS = galleryEl.querySelectorAll(config.selector); 1577 | 1578 | // Execute a few things once per element 1579 | LIGHTBOX_TRIGGER_GALLERY_ELS.forEach(lightboxTriggerEl => { 1580 | lightboxTriggerEl.setAttribute('data-group', `parvus-gallery-${GALLERY_INDEX}`); 1581 | add(lightboxTriggerEl); 1582 | }); 1583 | }); 1584 | } 1585 | 1586 | // Get a list of all `selector` elements outside or without the `gallerySelector` 1587 | const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(`${config.selector}:not(.parvus-trigger)`); 1588 | LIGHTBOX_TRIGGER_ELS.forEach(add); 1589 | }; 1590 | init(); 1591 | return { 1592 | init, 1593 | open, 1594 | close, 1595 | select, 1596 | previous, 1597 | next, 1598 | currentIndex: getCurrentIndex, 1599 | add, 1600 | remove, 1601 | destroy, 1602 | isOpen, 1603 | on, 1604 | off 1605 | }; 1606 | } 1607 | 1608 | export { Parvus as default }; 1609 | -------------------------------------------------------------------------------- /dist/js/parvus.esm.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parvus 3 | * 4 | * @author Benjamin de Oostfrees 5 | * @version 3.0.0 6 | * @url https://github.com/deoostfrees/parvus 7 | * 8 | * MIT license 9 | */ 10 | 11 | const e=['a:not([inert]):not([tabindex^="-"])','button:not([inert]):not([tabindex^="-"]):not(:disabled)','[tabindex]:not([inert]):not([tabindex^="-"])'],t=window,r=()=>t.innerWidth-document.documentElement.clientWidth,n=e=>{if(e.querySelector("img")&&null!==e.querySelector(".parvus-zoom__indicator")){const t=e.querySelector(".parvus-zoom__indicator");e.removeChild(t)}};var s={lightboxLabel:"This is a dialog window that overlays the main content of the page. The modal displays the enlarged image. Pressing the Escape key will close the modal and bring you back to where you were on the page.",lightboxLoadingIndicatorLabel:"Image loading",lightboxLoadingError:"The requested image cannot be loaded.",controlsLabel:"Controls",previousButtonLabel:"Previous image",nextButtonLabel:"Next image",closeButtonLabel:"Close dialog window",sliderLabel:"Images",slideLabel:"Image"};function i(t){const i=window,a={triggerElements:[],slider:null,sliderElements:[],contentElements:[]},l={},o=new Map;let d=0,u=null,c=null,m=0,p={},g=null,h=null,v=1,b=null,f=null,E=null,y=null,A=null,w=null,L=null,_=null,x={},C=!1,I=!1,M=!1,N=1,k=!1,T=!1,$=0,S=null,z=null,B=null,X=!1,Y=!0;const q=i.matchMedia("(prefers-reduced-motion)"),O=()=>{Y=!!q.matches},H=e=>{if(e.dataset.group)return e.dataset.group;const t="default-"+d++;return e.dataset.group=t,t},D=e=>{const t="A"===e.tagName&&e.hasAttribute("href"),r="BUTTON"===e.tagName&&e.hasAttribute("data-target");if(!t&&!r)throw new Error("Use a link with the 'href' attribute or a button with the 'data-target' attribute. Both attributes must contain a path to the image file.");if(g||F(),u=H(e),l[u]||(l[u]=structuredClone(a)),l[u].triggerElements.includes(e))throw new Error("Ups, element already added.");if(l[u].triggerElements.push(e),p.zoomIndicator&&((e,t)=>{if(e.querySelector("img")&&null===e.querySelector(".parvus-zoom__indicator")){const r=document.createElement("div");r.className="parvus-zoom__indicator",r.innerHTML=t.lightboxIndicatorIcon,e.appendChild(r)}})(e,p),e.classList.add("parvus-trigger"),e.addEventListener("click",oe),be()&&u===c){const t=l[u].triggerElements.indexOf(e);j(t),R(e,t,(()=>{U(t)})),se(),te(),re()}},F=()=>{const e=document.createDocumentFragment();g=document.createElement("dialog"),g.setAttribute("role","dialog"),g.setAttribute("aria-modal","true"),g.setAttribute("aria-label",p.l10n.lightboxLabel),g.classList.add("parvus"),h=document.createElement("div"),h.classList.add("parvus__overlay"),b=document.createElement("div"),b.className="parvus__toolbar",f=document.createElement("div"),E=document.createElement("div"),y=document.createElement("div"),y.className="parvus__controls",y.setAttribute("role","group"),y.setAttribute("aria-label",p.l10n.controlsLabel),L=document.createElement("button"),L.className="parvus__btn parvus__btn--close",L.setAttribute("type","button"),L.setAttribute("aria-label",p.l10n.closeButtonLabel),L.innerHTML=p.closeButtonIcon,A=document.createElement("button"),A.className="parvus__btn parvus__btn--previous",A.setAttribute("type","button"),A.setAttribute("aria-label",p.l10n.previousButtonLabel),A.innerHTML=p.previousButtonIcon,w=document.createElement("button"),w.className="parvus__btn parvus__btn--next",w.setAttribute("type","button"),w.setAttribute("aria-label",p.l10n.nextButtonLabel),w.innerHTML=p.nextButtonIcon,_=document.createElement("div"),_.className="parvus__counter",y.append(L,A,w),f.appendChild(_),E.appendChild(y),b.append(f,E),g.append(h,b),e.appendChild(g),document.body.appendChild(e)},j=e=>{if(void 0!==l[c].sliderElements[e])return;const t=document.createDocumentFragment(),r=document.createElement("div"),n=document.createElement("div"),s=l[c],i=s.triggerElements.length;if(r.className="parvus__slide",r.style.cssText=`\n position: absolute;\n left: ${100*e}%;\n `,r.setAttribute("aria-hidden","true"),i>1&&(r.setAttribute("role","group"),r.setAttribute("aria-label",`${p.l10n.slideLabel} ${e+1}/${i}`)),r.appendChild(n),t.appendChild(r),s.sliderElements[e]=r,e>=m){const t=(e=>{const t=l[c].sliderElements,r=t.length;for(let n=e+1;n${i}
`,e.appendChild(s),t.setAttribute("aria-describedby",r)}})(i,a,e,t),n[t]=r,r.setAttribute("width",r.naturalWidth),r.setAttribute("height",r.naturalHeight),ae(s[t],r)})).catch((()=>{const e=document.createElement("div");e.classList.add("parvus__content"),e.classList.add("parvus__content--error"),e.textContent=p.l10n.lightboxLoadingError,i.appendChild(e),n[t]=e})).finally((()=>{i.removeChild(u),r&&"function"==typeof r&&r()})),e.hasAttribute("data-sizes")&&""!==e.getAttribute("data-sizes")&&a.setAttribute("sizes",e.getAttribute("data-sizes")),e.hasAttribute("data-srcset")&&""!==e.getAttribute("data-srcset")&&a.setAttribute("srcset",e.getAttribute("data-srcset")),"A"===e.tagName?a.setAttribute("src",e.href):a.setAttribute("src",e.getAttribute("data-target")),d&&d.hasAttribute("alt")&&""!==d.getAttribute("alt")?a.alt=d.alt:e.hasAttribute("data-alt")&&""!==e.getAttribute("data-alt")?a.alt=e.getAttribute("data-alt"):a.alt=""},U=(e,t)=>{const r=l[c].contentElements[e];if(r&&"IMG"===r.tagName){const n=l[c].triggerElements[e];if(t&&document.startViewTransition){n.style.viewTransitionName="lightboximage";document.startViewTransition((()=>{r.style.opacity="",n.style.viewTransitionName=null,r.style.viewTransitionName="lightboximage"})).finished.finally((()=>{r.style.viewTransitionName=null}))}else r.style.opacity=""}else r.style.opacity=""},K=e=>{if(!be())throw new Error("Oops, I'm closed.");if("number"!=typeof e||isNaN(e))throw new Error("Oops, no slide specified.");const t=l[c],r=t.triggerElements;if(e===m)throw new Error(`Oops, slide ${e} is already selected.`);if(e<0||e>=r.length)throw new Error(`Oops, I can't find slide ${e}.`);const n=m;m=e,t.sliderElements[e]||(j(e),R(t.triggerElements[e],e,(()=>{U(e)}))),W(e),ee(),te(),re(),V(e${i}
`,e.appendChild(s),t.setAttribute("aria-describedby",r)}})(i,a,e,t),n[t]=r,r.setAttribute("width",r.naturalWidth),r.setAttribute("height",r.naturalHeight),ae(s[t],r)})).catch((()=>{const e=document.createElement("div");e.classList.add("parvus__content"),e.classList.add("parvus__content--error"),e.textContent=p.l10n.lightboxLoadingError,i.appendChild(e),n[t]=e})).finally((()=>{i.removeChild(u),r&&"function"==typeof r&&r()})),e.hasAttribute("data-sizes")&&""!==e.getAttribute("data-sizes")&&a.setAttribute("sizes",e.getAttribute("data-sizes")),e.hasAttribute("data-srcset")&&""!==e.getAttribute("data-srcset")&&a.setAttribute("srcset",e.getAttribute("data-srcset")),"A"===e.tagName?a.setAttribute("src",e.href):a.setAttribute("src",e.getAttribute("data-target")),d&&d.hasAttribute("alt")&&""!==d.getAttribute("alt")?a.alt=d.alt:e.hasAttribute("data-alt")&&""!==e.getAttribute("data-alt")?a.alt=e.getAttribute("data-alt"):a.alt=""},U=(e,t)=>{const r=l[c].contentElements[e];if(r&&"IMG"===r.tagName){const n=l[c].triggerElements[e];if(t&&document.startViewTransition){n.style.viewTransitionName="lightboximage";document.startViewTransition((()=>{r.style.opacity="",n.style.viewTransitionName=null,r.style.viewTransitionName="lightboximage"})).finished.finally((()=>{r.style.viewTransitionName=null}))}else r.style.opacity=""}else r.style.opacity=""},K=e=>{if(!be())throw new Error("Oops, I'm closed.");if("number"!=typeof e||isNaN(e))throw new Error("Oops, no slide specified.");const t=l[c],r=t.triggerElements;if(e===m)throw new Error(`Oops, slide ${e} is already selected.`);if(e<0||e>=r.length)throw new Error(`Oops, I can't find slide ${e}.`);const n=m;m=e,t.sliderElements[e]||(F(e),R(t.triggerElements[e],e,(()=>{U(e)}))),W(e),ee(),te(),re(),V(e${captionData}
` 648 | 649 | containerEl.appendChild(CAPTION_CONTAINER) 650 | 651 | imageEl.setAttribute('aria-describedby', CAPTION_ID) 652 | } 653 | } 654 | 655 | const createImage = (el, index, callback) => { 656 | const { contentElements, sliderElements } = GROUPS[activeGroup] 657 | 658 | if (contentElements[index] !== undefined) { 659 | if (callback && typeof callback === 'function') { 660 | callback() 661 | } 662 | return 663 | } 664 | 665 | const CONTENT_CONTAINER_EL = sliderElements[index].querySelector('div') 666 | const IMAGE = new Image() 667 | const IMAGE_CONTAINER = document.createElement('div') 668 | const THUMBNAIL = el.querySelector('img') 669 | const LOADING_INDICATOR = document.createElement('div') 670 | 671 | IMAGE_CONTAINER.className = 'parvus__content' 672 | 673 | // Create loading indicator 674 | LOADING_INDICATOR.className = 'parvus__loader' 675 | LOADING_INDICATOR.setAttribute('role', 'progressbar') 676 | LOADING_INDICATOR.setAttribute('aria-label', config.l10n.lightboxLoadingIndicatorLabel) 677 | 678 | // Add loading indicator to content container 679 | CONTENT_CONTAINER_EL.appendChild(LOADING_INDICATOR) 680 | 681 | const checkImagePromise = new Promise((resolve, reject) => { 682 | IMAGE.onload = () => resolve(IMAGE) 683 | IMAGE.onerror = (error) => reject(error) 684 | }) 685 | 686 | checkImagePromise 687 | .then((loadedImage) => { 688 | loadedImage.style.opacity = 0 689 | 690 | IMAGE_CONTAINER.appendChild(loadedImage) 691 | 692 | CONTENT_CONTAINER_EL.appendChild(IMAGE_CONTAINER) 693 | 694 | // Add caption if available 695 | if (config.captions) { 696 | addCaption(CONTENT_CONTAINER_EL, IMAGE, el, index) 697 | } 698 | 699 | contentElements[index] = loadedImage 700 | 701 | // Set image width and height 702 | loadedImage.setAttribute('width', loadedImage.naturalWidth) 703 | loadedImage.setAttribute('height', loadedImage.naturalHeight) 704 | 705 | // Set image dimension 706 | setImageDimension(sliderElements[index], loadedImage) 707 | }) 708 | .catch(() => { 709 | const ERROR_CONTAINER = document.createElement('div') 710 | 711 | ERROR_CONTAINER.classList.add('parvus__content') 712 | ERROR_CONTAINER.classList.add('parvus__content--error') 713 | 714 | ERROR_CONTAINER.textContent = config.l10n.lightboxLoadingError 715 | 716 | CONTENT_CONTAINER_EL.appendChild(ERROR_CONTAINER) 717 | 718 | contentElements[index] = ERROR_CONTAINER 719 | }) 720 | .finally(() => { 721 | CONTENT_CONTAINER_EL.removeChild(LOADING_INDICATOR) 722 | 723 | if (callback && typeof callback === 'function') { 724 | callback() 725 | } 726 | }) 727 | 728 | // Add `sizes` attribute 729 | if (el.hasAttribute('data-sizes') && el.getAttribute('data-sizes') !== '') { 730 | IMAGE.setAttribute('sizes', el.getAttribute('data-sizes')) 731 | } 732 | 733 | // Add `srcset` attribute 734 | if (el.hasAttribute('data-srcset') && el.getAttribute('data-srcset') !== '') { 735 | IMAGE.setAttribute('srcset', el.getAttribute('data-srcset')) 736 | } 737 | 738 | // Add `src` attribute 739 | if (el.tagName === 'A') { 740 | IMAGE.setAttribute('src', el.href) 741 | } else { 742 | IMAGE.setAttribute('src', el.getAttribute('data-target')) 743 | } 744 | 745 | // `alt` attribute 746 | if (THUMBNAIL && THUMBNAIL.hasAttribute('alt') && THUMBNAIL.getAttribute('alt') !== '') { 747 | IMAGE.alt = THUMBNAIL.alt 748 | } else if (el.hasAttribute('data-alt') && el.getAttribute('data-alt') !== '') { 749 | IMAGE.alt = el.getAttribute('data-alt') 750 | } else { 751 | IMAGE.alt = '' 752 | } 753 | } 754 | 755 | /** 756 | * Load Image 757 | * 758 | * @param {Number} index - The index of the image to load 759 | */ 760 | const loadImage = (index, animate) => { 761 | const IMAGE = GROUPS[activeGroup].contentElements[index] 762 | 763 | if (IMAGE && IMAGE.tagName === 'IMG') { 764 | const THUMBNAIL = GROUPS[activeGroup].triggerElements[index] 765 | 766 | if (animate && document.startViewTransition) { 767 | THUMBNAIL.style.viewTransitionName = 'lightboximage' 768 | 769 | const transition = document.startViewTransition(() => { 770 | IMAGE.style.opacity = '' 771 | THUMBNAIL.style.viewTransitionName = null 772 | 773 | IMAGE.style.viewTransitionName = 'lightboximage' 774 | }) 775 | 776 | transition.finished.finally(() => { 777 | IMAGE.style.viewTransitionName = null 778 | }) 779 | } else { 780 | IMAGE.style.opacity = '' 781 | } 782 | } else { 783 | IMAGE.style.opacity = '' 784 | } 785 | } 786 | 787 | /** 788 | * Select a specific slide by index 789 | * 790 | * @param {number} index - Index of the slide to select 791 | */ 792 | const select = (index) => { 793 | if (!isOpen()) { 794 | throw new Error("Oops, I'm closed.") 795 | } 796 | 797 | if (typeof index !== 'number' || isNaN(index)) { 798 | throw new Error('Oops, no slide specified.') 799 | } 800 | 801 | const GROUP = GROUPS[activeGroup] 802 | const triggerElements = GROUP.triggerElements 803 | 804 | if (index === currentIndex) { 805 | throw new Error(`Oops, slide ${index} is already selected.`) 806 | } 807 | 808 | if (index < 0 || index >= triggerElements.length) { 809 | throw new Error(`Oops, I can't find slide ${index}.`) 810 | } 811 | 812 | const OLD_INDEX = currentIndex 813 | 814 | currentIndex = index 815 | 816 | if (GROUP.sliderElements[index]) { 817 | loadSlide(index) 818 | } else { 819 | createSlide(index) 820 | createImage(GROUP.triggerElements[index], index, () => { 821 | loadImage(index) 822 | }) 823 | loadSlide(index) 824 | } 825 | 826 | updateOffset() 827 | updateSliderNavigationStatus() 828 | updateCounter() 829 | 830 | if (index < OLD_INDEX) { 831 | preload(index - 1) 832 | } else { 833 | preload(index + 1) 834 | } 835 | 836 | leaveSlide(OLD_INDEX) 837 | 838 | // Create and dispatch a new event 839 | dispatchCustomEvent('select') 840 | } 841 | 842 | /** 843 | * Select the previous slide 844 | * 845 | */ 846 | const previous = () => { 847 | if (currentIndex > 0) { 848 | select(currentIndex - 1) 849 | } 850 | } 851 | 852 | /** 853 | * Select the next slide 854 | * 855 | */ 856 | const next = () => { 857 | const { triggerElements } = GROUPS[activeGroup] 858 | 859 | if (currentIndex < triggerElements.length - 1) { 860 | select(currentIndex + 1) 861 | } 862 | } 863 | 864 | /** 865 | * Leave slide 866 | * 867 | * This function is called after moving the index to a new slide. 868 | * 869 | * @param {Number} index - The index of the slide to leave. 870 | */ 871 | const leaveSlide = (index) => { 872 | if (GROUPS[activeGroup].sliderElements[index] !== undefined) { 873 | GROUPS[activeGroup].sliderElements[index].setAttribute('aria-hidden', 'true') 874 | } 875 | } 876 | 877 | /** 878 | * Update offset 879 | * 880 | */ 881 | const updateOffset = () => { 882 | activeGroup = activeGroup !== null ? activeGroup : newGroup 883 | 884 | offset = -currentIndex * lightbox.offsetWidth 885 | 886 | GROUPS[activeGroup].slider.style.transform = `translate3d(${offset}px, 0, 0)` 887 | offsetTmp = offset 888 | } 889 | 890 | /** 891 | * Update slider navigation status 892 | * 893 | * This function updates the disabled status of the slider navigation buttons 894 | * based on the current slide position. 895 | * 896 | */ 897 | const updateSliderNavigationStatus = () => { 898 | const { triggerElements } = GROUPS[activeGroup] 899 | const TOTAL_TRIGGER_ELEMENTS = triggerElements.length 900 | 901 | if (TOTAL_TRIGGER_ELEMENTS <= 1) { 902 | return 903 | } 904 | 905 | // Determine navigation state 906 | const FIRST_SLIDE = currentIndex === 0 907 | const LAST_SLIDE = currentIndex === TOTAL_TRIGGER_ELEMENTS - 1 908 | 909 | // Set previous button state 910 | const PREV_DISABLED = FIRST_SLIDE ? 'true' : null 911 | 912 | if ((previousButton.getAttribute('aria-disabled') === 'true') !== !!PREV_DISABLED) { 913 | PREV_DISABLED 914 | ? previousButton.setAttribute('aria-disabled', 'true') 915 | : previousButton.removeAttribute('aria-disabled') 916 | } 917 | 918 | // Set next button state 919 | const NEXT_DISABLED = LAST_SLIDE ? 'true' : null 920 | 921 | if ((nextButton.getAttribute('aria-disabled') === 'true') !== !!NEXT_DISABLED) { 922 | NEXT_DISABLED 923 | ? nextButton.setAttribute('aria-disabled', 'true') 924 | : nextButton.removeAttribute('aria-disabled') 925 | } 926 | } 927 | 928 | /** 929 | * Update counter 930 | * 931 | * This function updates the counter display based on the current slide index. 932 | */ 933 | const updateCounter = () => { 934 | counter.textContent = `${currentIndex + 1}/${GROUPS[activeGroup].triggerElements.length}` 935 | } 936 | 937 | /** 938 | * Clear drag after pointerup event 939 | * 940 | * This function clears the drag state after the pointerup event is triggered. 941 | */ 942 | const clearDrag = () => { 943 | drag = { 944 | startX: 0, 945 | endX: 0, 946 | startY: 0, 947 | endY: 0 948 | } 949 | } 950 | 951 | /** 952 | * Recalculate drag/swipe event 953 | * 954 | */ 955 | const updateAfterDrag = () => { 956 | const { startX, startY, endX, endY } = drag 957 | const MOVEMENT_X = endX - startX 958 | const MOVEMENT_Y = endY - startY 959 | const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X) 960 | const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y) 961 | const { triggerElements } = GROUPS[activeGroup] 962 | const TOTAL_TRIGGER_ELEMENTS = triggerElements.length 963 | 964 | if (isDraggingX) { 965 | const IS_RIGHT_SWIPE = MOVEMENT_X > 0 966 | 967 | if (MOVEMENT_X_DISTANCE >= config.threshold) { 968 | if (IS_RIGHT_SWIPE && currentIndex > 0) { 969 | previous() 970 | } else if (!IS_RIGHT_SWIPE && currentIndex < TOTAL_TRIGGER_ELEMENTS - 1) { 971 | next() 972 | } 973 | } 974 | 975 | updateOffset() 976 | } else if (isDraggingY) { 977 | if (MOVEMENT_Y_DISTANCE >= config.threshold && config.swipeClose) { 978 | close() 979 | } else { 980 | lightbox.classList.remove('parvus--is-vertical-closing') 981 | 982 | updateOffset() 983 | } 984 | 985 | lightboxOverlay.style.opacity = '' 986 | } else { 987 | updateOffset() 988 | } 989 | } 990 | 991 | /** 992 | * Update Attributes 993 | * 994 | */ 995 | const updateAttributes = () => { 996 | const TRIGGER_ELEMENTS = GROUPS[activeGroup].triggerElements 997 | const TOTAL_TRIGGER_ELEMENTS = TRIGGER_ELEMENTS.length 998 | 999 | const SLIDER = GROUPS[activeGroup].slider 1000 | const SLIDER_ELEMENTS = GROUPS[activeGroup].sliderElements 1001 | 1002 | const IS_DRAGGABLE = SLIDER.classList.contains('parvus__slider--is-draggable') 1003 | 1004 | // Add draggable class if neccesary 1005 | if ((config.simulateTouch && config.swipeClose && !IS_DRAGGABLE) || (config.simulateTouch && TOTAL_TRIGGER_ELEMENTS > 1 && !IS_DRAGGABLE)) { 1006 | SLIDER.classList.add('parvus__slider--is-draggable') 1007 | } else { 1008 | SLIDER.classList.remove('parvus__slider--is-draggable') 1009 | } 1010 | 1011 | // Add extra output for screen reader if there is more than one slide 1012 | if (TOTAL_TRIGGER_ELEMENTS > 1) { 1013 | SLIDER.setAttribute('role', 'region') 1014 | SLIDER.setAttribute('aria-roledescription', 'carousel') 1015 | SLIDER.setAttribute('aria-label', config.l10n.sliderLabel) 1016 | 1017 | SLIDER_ELEMENTS.forEach((sliderElement, index) => { 1018 | sliderElement.setAttribute('role', 'group') 1019 | sliderElement.setAttribute('aria-label', `${config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`) 1020 | }) 1021 | } else { 1022 | SLIDER.removeAttribute('role') 1023 | SLIDER.removeAttribute('aria-roledescription') 1024 | SLIDER.removeAttribute('aria-label') 1025 | 1026 | SLIDER_ELEMENTS.forEach((sliderElement) => { 1027 | sliderElement.removeAttribute('role') 1028 | sliderElement.removeAttribute('aria-label') 1029 | }) 1030 | } 1031 | 1032 | // Show or hide buttons 1033 | if (TOTAL_TRIGGER_ELEMENTS === 1) { 1034 | counter.setAttribute('aria-hidden', 'true') 1035 | 1036 | previousButton.setAttribute('aria-hidden', 'true') 1037 | 1038 | nextButton.setAttribute('aria-hidden', 'true') 1039 | } else { 1040 | counter.removeAttribute('aria-hidden') 1041 | 1042 | previousButton.removeAttribute('aria-hidden') 1043 | 1044 | nextButton.removeAttribute('aria-hidden') 1045 | } 1046 | } 1047 | 1048 | /** 1049 | * Resize event handler 1050 | * 1051 | */ 1052 | const resizeHandler = () => { 1053 | if (!resizeTicking) { 1054 | resizeTicking = true 1055 | 1056 | BROWSER_WINDOW.requestAnimationFrame(() => { 1057 | GROUPS[activeGroup].sliderElements.forEach((slide, index) => { 1058 | setImageDimension(slide, GROUPS[activeGroup].contentElements[index]) 1059 | }) 1060 | 1061 | updateOffset() 1062 | 1063 | resizeTicking = false 1064 | }) 1065 | } 1066 | } 1067 | 1068 | /** 1069 | * Set image dimension 1070 | * 1071 | * @param {HTMLElement} slideEl - The slide element 1072 | * @param {HTMLElement} contentEl - The content element 1073 | */ 1074 | const setImageDimension = (slideEl, contentEl) => { 1075 | if (contentEl.tagName !== 'IMG') { 1076 | return 1077 | } 1078 | 1079 | const SRC_HEIGHT = contentEl.getAttribute('height') 1080 | const SRC_WIDTH = contentEl.getAttribute('width') 1081 | 1082 | if (!SRC_HEIGHT || !SRC_WIDTH) { 1083 | return 1084 | } 1085 | 1086 | const SLIDE_EL_STYLES = getComputedStyle(slideEl) 1087 | 1088 | const HORIZONTAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingLeft) + parseFloat(SLIDE_EL_STYLES.paddingRight) 1089 | const VERTICAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingTop) + parseFloat(SLIDE_EL_STYLES.paddingBottom) 1090 | 1091 | const CAPTION_EL = slideEl.querySelector('.parvus__caption') 1092 | const CAPTION_HEIGHT = CAPTION_EL ? CAPTION_EL.getBoundingClientRect().height : 0 1093 | 1094 | const MAX_WIDTH = slideEl.offsetWidth - HORIZONTAL_PADDING 1095 | const MAX_HEIGHT = slideEl.offsetHeight - VERTICAL_PADDING - CAPTION_HEIGHT 1096 | 1097 | const RATIO = Math.min(MAX_WIDTH / SRC_WIDTH || 0, MAX_HEIGHT / SRC_HEIGHT || 0) 1098 | 1099 | const NEW_WIDTH = SRC_WIDTH * RATIO 1100 | const NEW_HEIGHT = SRC_HEIGHT * RATIO 1101 | 1102 | const USE_ORIGINAL_SIZE = (SRC_WIDTH <= MAX_WIDTH && SRC_HEIGHT <= MAX_HEIGHT) 1103 | 1104 | contentEl.style.width = USE_ORIGINAL_SIZE ? '' : `${NEW_WIDTH}px` 1105 | contentEl.style.height = USE_ORIGINAL_SIZE ? '' : `${NEW_HEIGHT}px` 1106 | } 1107 | 1108 | /** 1109 | * Reset image zoom 1110 | * 1111 | * @param {HTMLImageElement} currentImg - The image 1112 | */ 1113 | const resetZoom = (currentImg) => { 1114 | currentImg.style.transition = 'transform 0.3s ease' 1115 | currentImg.style.transform = '' 1116 | 1117 | setTimeout(() => { 1118 | currentImg.style.transition = '' 1119 | currentImg.style.transformOrigin = '' 1120 | }, 300) 1121 | 1122 | isPinching = false 1123 | 1124 | isTap = false 1125 | 1126 | currentScale = 1 1127 | pinchStartDistance = 0 1128 | lastPointersId = '' 1129 | 1130 | lightbox.classList.remove('parvus--is-zooming') 1131 | } 1132 | 1133 | /** 1134 | * Pinch zoom gesture 1135 | * 1136 | * @param {HTMLImageElement} currentImg - The image to zoom 1137 | */ 1138 | const pinchZoom = (currentImg) => { 1139 | // Determine current finger positions 1140 | const POINTS = Array.from(activePointers.values()) 1141 | 1142 | // Calculate current distance between fingers 1143 | const CURRENT_DISTANCE = Math.hypot( 1144 | POINTS[1].clientX - POINTS[0].clientX, 1145 | POINTS[1].clientY - POINTS[0].clientY 1146 | ) 1147 | 1148 | // Calculate the midpoint between the two points 1149 | const MIDPOINT_X = (POINTS[0].clientX + POINTS[1].clientX) / 2 1150 | const MIDPOINT_Y = (POINTS[0].clientY + POINTS[1].clientY) / 2 1151 | 1152 | // Convert midpoint to relative position within the image 1153 | const IMG_RECT = currentImg.getBoundingClientRect() 1154 | const RELATIVE_X = (MIDPOINT_X - IMG_RECT.left) / IMG_RECT.width 1155 | const RELATIVE_Y = (MIDPOINT_Y - IMG_RECT.top) / IMG_RECT.height 1156 | 1157 | // When pinch gesture is about to start or the finger IDs have changed 1158 | // Use a unique ID based on the pointer IDs to recognize changes 1159 | const CURRENT_POINTERS_ID = POINTS.map(p => p.pointerId).sort().join('-') 1160 | const IS_NEW_POINTER_COMBINATION = lastPointersId !== CURRENT_POINTERS_ID 1161 | 1162 | if (!isPinching || IS_NEW_POINTER_COMBINATION) { 1163 | isPinching = true 1164 | lastPointersId = CURRENT_POINTERS_ID 1165 | 1166 | // Save the start distance and current scaling as a basis 1167 | pinchStartDistance = CURRENT_DISTANCE / currentScale 1168 | 1169 | // Store initial pinch position for this gesture 1170 | if ((!currentImg.style.transformOrigin && currentScale === 1) || 1171 | (currentScale === 1 && IS_NEW_POINTER_COMBINATION)) { 1172 | // Set the transform origin to the pinch midpoint 1173 | currentImg.style.transformOrigin = `${RELATIVE_X * 100}% ${RELATIVE_Y * 100}%` 1174 | } 1175 | 1176 | lightbox.classList.add('parvus--is-zooming') 1177 | } 1178 | 1179 | // Calculate scaling factor based on distance change 1180 | const SCALE_FACTOR = CURRENT_DISTANCE / pinchStartDistance 1181 | 1182 | // Limit scaling to 1 - 3 1183 | currentScale = Math.min(Math.max(1, SCALE_FACTOR), 3) 1184 | 1185 | currentImg.style.willChange = 'transform' 1186 | currentImg.style.transform = `scale(${currentScale})` 1187 | } 1188 | 1189 | /** 1190 | * Click event handler to trigger Parvus 1191 | * 1192 | * @param {Event} event - The click event object 1193 | */ 1194 | const triggerParvus = function triggerParvus (event) { 1195 | event.preventDefault() 1196 | 1197 | open(this) 1198 | } 1199 | 1200 | /** 1201 | * Event handler for click events 1202 | * 1203 | * @param {Event} event - The click event object 1204 | */ 1205 | const clickHandler = (event) => { 1206 | const { target } = event 1207 | 1208 | if (target === previousButton) { 1209 | previous() 1210 | } else if (target === nextButton) { 1211 | next() 1212 | } else if (target === closeButton || (config.docClose && !isDraggingY && !isDraggingX && target.classList.contains('parvus__slide'))) { 1213 | close() 1214 | } 1215 | 1216 | event.stopPropagation() 1217 | } 1218 | 1219 | /** 1220 | * Event handler for the keydown event 1221 | * 1222 | * @param {Event} event - The keydown event object 1223 | */ 1224 | const keydownHandler = (event) => { 1225 | const FOCUSABLE_CHILDREN = getFocusableChildren(lightbox) 1226 | const FOCUSED_ITEM_INDEX = FOCUSABLE_CHILDREN.indexOf(document.activeElement) 1227 | const lastIndex = FOCUSABLE_CHILDREN.length - 1 1228 | 1229 | switch (event.code) { 1230 | case 'Tab': { 1231 | // Use the TAB key to navigate backwards and forwards 1232 | if (event.shiftKey) { 1233 | // Navigate backwards 1234 | if (FOCUSED_ITEM_INDEX === 0) { 1235 | FOCUSABLE_CHILDREN[lastIndex].focus() 1236 | event.preventDefault() 1237 | } 1238 | } else { 1239 | // Navigate forwards 1240 | if (FOCUSED_ITEM_INDEX === lastIndex) { 1241 | FOCUSABLE_CHILDREN[0].focus() 1242 | event.preventDefault() 1243 | } 1244 | } 1245 | break 1246 | } 1247 | case 'Escape': { 1248 | // Close Parvus when the ESC key is pressed 1249 | close() 1250 | event.preventDefault() 1251 | break 1252 | } 1253 | case 'ArrowLeft': { 1254 | // Show the previous slide when the PREV key is pressed 1255 | previous() 1256 | event.preventDefault() 1257 | break 1258 | } 1259 | case 'ArrowRight': { 1260 | // Show the next slide when the NEXT key is pressed 1261 | next() 1262 | event.preventDefault() 1263 | break 1264 | } 1265 | } 1266 | } 1267 | 1268 | /** 1269 | * Event handler for the pointerdown event. 1270 | * 1271 | * This function is triggered when a pointer becomes active buttons state. 1272 | * It handles the necessary actions and logic related to the pointerdown event. 1273 | * 1274 | * @param {Event} event - The pointerdown event object 1275 | */ 1276 | const pointerdownHandler = (event) => { 1277 | event.preventDefault() 1278 | event.stopPropagation() 1279 | 1280 | isDraggingX = false 1281 | isDraggingY = false 1282 | 1283 | pointerDown = true 1284 | 1285 | activePointers.set(event.pointerId, event) 1286 | 1287 | drag.startX = event.pageX 1288 | drag.startY = event.pageY 1289 | drag.endX = event.pageX 1290 | drag.endY = event.pageY 1291 | 1292 | const { slider } = GROUPS[activeGroup] 1293 | 1294 | slider.classList.add('parvus__slider--is-dragging') 1295 | slider.style.willChange = 'transform' 1296 | 1297 | isTap = activePointers.size === 1 1298 | 1299 | if (config.swipeClose) { 1300 | lightboxOverlayOpacity = getComputedStyle(lightboxOverlay).opacity 1301 | } 1302 | } 1303 | 1304 | /** 1305 | * Event handler for the pointermove event. 1306 | * 1307 | * This function is triggered when a pointer changes coordinates. 1308 | * It handles the necessary actions and logic related to the pointermove event. 1309 | * 1310 | * @param {Event} event - The pointermove event object 1311 | */ 1312 | const pointermoveHandler = (event) => { 1313 | event.preventDefault() 1314 | 1315 | if (!pointerDown) { 1316 | return 1317 | } 1318 | 1319 | const CURRENT_IMAGE = GROUPS[activeGroup].contentElements[currentIndex] 1320 | 1321 | // Update pointer position 1322 | activePointers.set(event.pointerId, event) 1323 | 1324 | // Zoom 1325 | if (CURRENT_IMAGE && CURRENT_IMAGE.tagName === 'IMG') { 1326 | if (activePointers.size === 2) { 1327 | pinchZoom(CURRENT_IMAGE) 1328 | 1329 | return 1330 | } 1331 | 1332 | if (currentScale > 1) { 1333 | return 1334 | } 1335 | } 1336 | 1337 | drag.endX = event.pageX 1338 | drag.endY = event.pageY 1339 | 1340 | doSwipe() 1341 | } 1342 | 1343 | /** 1344 | * Event handler for the pointerup event. 1345 | * 1346 | * This function is triggered when a pointer is no longer active buttons state. 1347 | * It handles the necessary actions and logic related to the pointerup event. 1348 | * 1349 | * @param {Event} event - The pointerup event object 1350 | */ 1351 | const pointerupHandler = (event) => { 1352 | event.stopPropagation() 1353 | 1354 | const { slider } = GROUPS[activeGroup] 1355 | 1356 | activePointers.delete(event.pointerId) 1357 | 1358 | if (activePointers.size > 0) { 1359 | return 1360 | } 1361 | 1362 | pointerDown = false 1363 | 1364 | const CURRENT_IMAGE = GROUPS[activeGroup].contentElements[currentIndex] 1365 | 1366 | // Reset zoom state by one tap 1367 | const MOVEMENT_X = Math.abs(drag.endX - drag.startX) 1368 | const MOVEMENT_Y = Math.abs(drag.endY - drag.startY) 1369 | 1370 | const IS_TAP = MOVEMENT_X < 8 && MOVEMENT_Y < 8 && !isDraggingX && !isDraggingY && isTap 1371 | 1372 | slider.classList.remove('parvus__slider--is-dragging') 1373 | slider.style.willChange = '' 1374 | 1375 | if (currentScale > 1) { 1376 | if (IS_TAP) { 1377 | resetZoom(CURRENT_IMAGE) 1378 | } else { 1379 | CURRENT_IMAGE.style.transform = ` 1380 | scale(${currentScale}) 1381 | ` 1382 | } 1383 | } else { 1384 | if (isPinching) { 1385 | resetZoom(CURRENT_IMAGE) 1386 | } 1387 | 1388 | if (drag.endX || drag.endY) { 1389 | updateAfterDrag() 1390 | } 1391 | } 1392 | 1393 | clearDrag() 1394 | } 1395 | 1396 | /** 1397 | * Determine the swipe direction (horizontal or vertical). 1398 | * 1399 | * This function analyzes the swipe gesture and decides whether it is a horizontal 1400 | * or vertical swipe based on the direction and angle of the swipe. 1401 | */ 1402 | const doSwipe = () => { 1403 | const MOVEMENT_THRESHOLD = 1.5 1404 | const MAX_OPACITY_DISTANCE = 100 1405 | const DIRECTION_BIAS = 1.15 1406 | 1407 | const { startX, endX, startY, endY } = drag 1408 | const MOVEMENT_X = startX - endX 1409 | const MOVEMENT_Y = endY - startY 1410 | const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X) 1411 | const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y) 1412 | 1413 | const GROUP = GROUPS[activeGroup] 1414 | const SLIDER = GROUP.slider 1415 | const TOTAL_SLIDES = GROUP.triggerElements.length 1416 | 1417 | const handleHorizontalSwipe = (movementX, distance) => { 1418 | const IS_FIRST_SLIDE = currentIndex === 0 1419 | const IS_LAST_SLIDE = currentIndex === TOTAL_SLIDES - 1 1420 | 1421 | const IS_LEFT_SWIPE = movementX > 0 1422 | const IS_RIGHT_SWIPE = movementX < 0 1423 | 1424 | if ((IS_FIRST_SLIDE && IS_RIGHT_SWIPE) || (IS_LAST_SLIDE && IS_LEFT_SWIPE)) { 1425 | const DAMPING_FACTOR = 1 / (1 + Math.pow(distance / 100, 0.15)) 1426 | const REDUCED_MOVEMENT = movementX * DAMPING_FACTOR 1427 | 1428 | SLIDER.style.transform = ` 1429 | translate3d(${offsetTmp - Math.round(REDUCED_MOVEMENT)}px, 0, 0) 1430 | ` 1431 | } else { 1432 | SLIDER.style.transform = ` 1433 | translate3d(${offsetTmp - Math.round(movementX)}px, 0, 0) 1434 | ` 1435 | } 1436 | } 1437 | 1438 | const handleVerticalSwipe = (movementY, distance) => { 1439 | if (!isReducedMotion && distance <= 100) { 1440 | const NEW_OVERLAY_OPACITY = Math.max(0, lightboxOverlayOpacity - (distance / MAX_OPACITY_DISTANCE)) 1441 | 1442 | lightboxOverlay.style.opacity = NEW_OVERLAY_OPACITY 1443 | } 1444 | 1445 | lightbox.classList.add('parvus--is-vertical-closing') 1446 | 1447 | SLIDER.style.transform = ` 1448 | translate3d(${offsetTmp}px, ${Math.round(movementY)}px, 0) 1449 | ` 1450 | } 1451 | 1452 | if (isDraggingX || isDraggingY) { 1453 | if (isDraggingX) { 1454 | handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE) 1455 | } else if (isDraggingY) { 1456 | handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE) 1457 | } 1458 | return 1459 | } 1460 | 1461 | // Direction detection based on the relative ratio of movements 1462 | if (MOVEMENT_X_DISTANCE > MOVEMENT_THRESHOLD || MOVEMENT_Y_DISTANCE > MOVEMENT_THRESHOLD) { 1463 | // Horizontal swipe if X-movement is stronger than Y-movement * DIRECTION_BIAS 1464 | if (MOVEMENT_X_DISTANCE > MOVEMENT_Y_DISTANCE * DIRECTION_BIAS && TOTAL_SLIDES > 1) { 1465 | isDraggingX = true 1466 | isDraggingY = false 1467 | 1468 | handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE) 1469 | } else if (MOVEMENT_Y_DISTANCE > MOVEMENT_X_DISTANCE * DIRECTION_BIAS && config.swipeClose) { 1470 | // Vertical swipe if Y-movement is stronger than X-movement * DIRECTION_BIAS 1471 | isDraggingX = false 1472 | isDraggingY = true 1473 | 1474 | handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE) 1475 | } 1476 | } 1477 | } 1478 | 1479 | /** 1480 | * Bind specified events 1481 | * 1482 | */ 1483 | const bindEvents = () => { 1484 | BROWSER_WINDOW.addEventListener('keydown', keydownHandler) 1485 | BROWSER_WINDOW.addEventListener('resize', resizeHandler) 1486 | 1487 | // Popstate event 1488 | BROWSER_WINDOW.addEventListener('popstate', close) 1489 | 1490 | // Check for any OS level changes to the prefers reduced motion preference 1491 | MOTIONQUERY.addEventListener('change', reducedMotionCheck) 1492 | 1493 | // Click event 1494 | lightbox.addEventListener('click', clickHandler) 1495 | 1496 | // Pointer events 1497 | lightbox.addEventListener('pointerdown', pointerdownHandler, { passive: false }) 1498 | lightbox.addEventListener('pointerup', pointerupHandler, { passive: true }) 1499 | lightbox.addEventListener('pointermove', pointermoveHandler, { passive: false }) 1500 | } 1501 | 1502 | /** 1503 | * Unbind specified events 1504 | * 1505 | */ 1506 | const unbindEvents = () => { 1507 | BROWSER_WINDOW.removeEventListener('keydown', keydownHandler) 1508 | BROWSER_WINDOW.removeEventListener('resize', resizeHandler) 1509 | 1510 | // Popstate event 1511 | BROWSER_WINDOW.removeEventListener('popstate', close) 1512 | 1513 | // Check for any OS level changes to the prefers reduced motion preference 1514 | MOTIONQUERY.removeEventListener('change', reducedMotionCheck) 1515 | 1516 | // Click event 1517 | lightbox.removeEventListener('click', clickHandler) 1518 | 1519 | // Pointer events 1520 | lightbox.removeEventListener('pointerdown', pointerdownHandler) 1521 | lightbox.removeEventListener('pointerup', pointerupHandler) 1522 | lightbox.removeEventListener('pointermove', pointermoveHandler) 1523 | } 1524 | 1525 | /** 1526 | * Destroy Parvus 1527 | * 1528 | */ 1529 | const destroy = () => { 1530 | if (!lightbox) { 1531 | return 1532 | } 1533 | 1534 | if (isOpen()) { 1535 | close() 1536 | } 1537 | 1538 | // Add setTimeout to ensure all possible close transitions are completed 1539 | setTimeout(() => { 1540 | unbindEvents() 1541 | 1542 | // Remove all registered event listeners for custom events 1543 | const eventTypes = [ 1544 | 'open', 1545 | 'close', 1546 | 'select', 1547 | 'destroy' 1548 | ] 1549 | 1550 | eventTypes.forEach(eventType => { 1551 | const listeners = lightbox._listeners?.[eventType] || [] 1552 | 1553 | listeners.forEach(listener => { 1554 | lightbox.removeEventListener(eventType, listener) 1555 | }) 1556 | }) 1557 | 1558 | // Remove event listeners from trigger elements 1559 | const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll('.parvus-trigger') 1560 | 1561 | LIGHTBOX_TRIGGER_ELS.forEach(el => { 1562 | el.removeEventListener('click', triggerParvus) 1563 | el.classList.remove('parvus-trigger') 1564 | 1565 | if (config.zoomIndicator) { 1566 | removeZoomIndicator(el) 1567 | } 1568 | 1569 | if (el.dataset.group) { 1570 | delete el.dataset.group 1571 | } 1572 | }) 1573 | 1574 | // Create and dispatch a new event 1575 | dispatchCustomEvent('destroy') 1576 | 1577 | lightbox.remove() 1578 | 1579 | // Remove references 1580 | lightbox = null 1581 | lightboxOverlay = null 1582 | toolbar = null 1583 | toolbarLeft = null 1584 | toolbarRight = null 1585 | controls = null 1586 | previousButton = null 1587 | nextButton = null 1588 | closeButton = null 1589 | counter = null 1590 | 1591 | // Remove group data 1592 | Object.keys(GROUPS).forEach(groupKey => { 1593 | const group = GROUPS[groupKey] 1594 | 1595 | if (group && group.contentElements) { 1596 | group.contentElements.forEach(content => { 1597 | if (content && content.tagName === 'IMG') { 1598 | content.src = '' 1599 | content.srcset = '' 1600 | } 1601 | }) 1602 | } 1603 | delete GROUPS[groupKey] 1604 | }) 1605 | 1606 | // Reset variables 1607 | groupIdCounter = 0 1608 | newGroup = null 1609 | activeGroup = null 1610 | currentIndex = 0 1611 | }, 1000) 1612 | } 1613 | 1614 | /** 1615 | * Check if Parvus is open 1616 | * 1617 | * @returns {boolean} - True if Parvus is open, otherwise false 1618 | */ 1619 | const isOpen = () => { 1620 | return lightbox.hasAttribute('open') 1621 | } 1622 | 1623 | /** 1624 | * Get the current index 1625 | * 1626 | * @returns {number} - The current index 1627 | */ 1628 | const getCurrentIndex = () => { 1629 | return currentIndex 1630 | } 1631 | 1632 | /** 1633 | * Dispatch a custom event 1634 | * 1635 | * @param {String} type - The type of the event to dispatch 1636 | */ 1637 | const dispatchCustomEvent = (type) => { 1638 | const CUSTOM_EVENT = new CustomEvent(type, { 1639 | cancelable: true 1640 | }) 1641 | 1642 | lightbox.dispatchEvent(CUSTOM_EVENT) 1643 | } 1644 | 1645 | /** 1646 | * Bind a specific event listener 1647 | * 1648 | * @param {String} eventName - The name of the event to Bind 1649 | * @param {Function} callback - The callback function 1650 | */ 1651 | const on = (eventName, callback) => { 1652 | if (lightbox) { 1653 | lightbox.addEventListener(eventName, callback) 1654 | } 1655 | } 1656 | 1657 | /** 1658 | * Unbind a specific event listener 1659 | * 1660 | * @param {String} eventName - The name of the event to unbind 1661 | * @param {Function} callback - The callback function 1662 | */ 1663 | const off = (eventName, callback) => { 1664 | if (lightbox) { 1665 | lightbox.removeEventListener(eventName, callback) 1666 | } 1667 | } 1668 | 1669 | /** 1670 | * Init 1671 | * 1672 | */ 1673 | const init = () => { 1674 | // Merge user options into defaults 1675 | config = mergeOptions(userOptions) 1676 | 1677 | reducedMotionCheck() 1678 | 1679 | if (config.gallerySelector !== null) { 1680 | // Get a list of all `gallerySelector` elements within the document 1681 | const GALLERY_ELS = document.querySelectorAll(config.gallerySelector) 1682 | 1683 | // Execute a few things once per element 1684 | GALLERY_ELS.forEach((galleryEl, index) => { 1685 | const GALLERY_INDEX = index 1686 | // Get a list of all `selector` elements within the `gallerySelector` 1687 | const LIGHTBOX_TRIGGER_GALLERY_ELS = galleryEl.querySelectorAll(config.selector) 1688 | 1689 | // Execute a few things once per element 1690 | LIGHTBOX_TRIGGER_GALLERY_ELS.forEach((lightboxTriggerEl) => { 1691 | lightboxTriggerEl.setAttribute('data-group', `parvus-gallery-${GALLERY_INDEX}`) 1692 | add(lightboxTriggerEl) 1693 | }) 1694 | }) 1695 | } 1696 | 1697 | // Get a list of all `selector` elements outside or without the `gallerySelector` 1698 | const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(`${config.selector}:not(.parvus-trigger)`) 1699 | 1700 | LIGHTBOX_TRIGGER_ELS.forEach(add) 1701 | } 1702 | 1703 | init() 1704 | 1705 | return { 1706 | init, 1707 | open, 1708 | close, 1709 | select, 1710 | previous, 1711 | next, 1712 | currentIndex: getCurrentIndex, 1713 | add, 1714 | remove, 1715 | destroy, 1716 | isOpen, 1717 | on, 1718 | off 1719 | } 1720 | } 1721 | -------------------------------------------------------------------------------- /src/js/zoom-indicator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Add zoom indicator to element 3 | * 4 | * @param {HTMLElement} el - The element to add the zoom indicator to 5 | * @param {Object} config - Options object 6 | */ 7 | export const addZoomIndicator = (el, config) => { 8 | if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') === null) { 9 | const LIGHTBOX_INDICATOR_ICON = document.createElement('div') 10 | 11 | LIGHTBOX_INDICATOR_ICON.className = 'parvus-zoom__indicator' 12 | LIGHTBOX_INDICATOR_ICON.innerHTML = config.lightboxIndicatorIcon 13 | 14 | el.appendChild(LIGHTBOX_INDICATOR_ICON) 15 | } 16 | } 17 | 18 | /** 19 | * Remove zoom indicator for element 20 | * 21 | * @param {HTMLElement} el - The element to remove the zoom indicator to 22 | */ 23 | export const removeZoomIndicator = (el) => { 24 | if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') !== null) { 25 | const LIGHTBOX_INDICATOR_ICON = el.querySelector('.parvus-zoom__indicator') 26 | 27 | el.removeChild(LIGHTBOX_INDICATOR_ICON) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/l10n/de.js: -------------------------------------------------------------------------------- 1 | export default { 2 | lightboxLabel: 'Dies ist ein Dialogfenster, das den Hauptinhalt der Seite überlagert. Das Modal zeigt das vergrößerte Bild an. Durch Drücken der Escape-Taste wird das Modal geschlossen und Sie gelangen zurück zu Ihrem vorherigen Standpunkt auf der Seite.', 3 | lightboxLoadingIndicatorLabel: 'Bild wird geladen', 4 | lightboxLoadingError: 'Das angeforderte Bild kann nicht geladen werden.', 5 | controlsLabel: 'Steuerungen', 6 | previousButtonLabel: 'Vorheriges Bild', 7 | nextButtonLabel: 'Nächstes Bild', 8 | closeButtonLabel: 'Dialogfenster schließen', 9 | sliderLabel: 'Bilder', 10 | slideLabel: 'Bild' 11 | } 12 | -------------------------------------------------------------------------------- /src/l10n/en.js: -------------------------------------------------------------------------------- 1 | export default { 2 | lightboxLabel: 'This is a dialog window that overlays the main content of the page. The modal displays the enlarged image. Pressing the Escape key will close the modal and bring you back to where you were on the page.', 3 | lightboxLoadingIndicatorLabel: 'Image loading', 4 | lightboxLoadingError: 'The requested image cannot be loaded.', 5 | controlsLabel: 'Controls', 6 | previousButtonLabel: 'Previous image', 7 | nextButtonLabel: 'Next image', 8 | closeButtonLabel: 'Close dialog window', 9 | sliderLabel: 'Images', 10 | slideLabel: 'Image' 11 | } 12 | -------------------------------------------------------------------------------- /src/l10n/nl.js: -------------------------------------------------------------------------------- 1 | export default { 2 | lightboxLabel: 'Dit is een dialoogvenster dat over de hoofdinhoud van de pagina wordt geplaatst. Hierin wordt de afbeelding in het groot weergegeven. Door op de Escape-toets te drukken, wordt het venster gesloten en word je teruggebracht naar waar je was op de pagina.', 3 | lightboxLoadingIndicatorLabel: 'Afbeelding wordt geladen', 4 | lightboxLoadingError: 'De gevraagde afbeelding kan niet worden geladen.', 5 | controlsLabel: 'Bedieningselementen', 6 | previousButtonLabel: 'Vorige afbeelding', 7 | nextButtonLabel: 'Volgende afbeelding', 8 | closeButtonLabel: 'Sluit dialoogvenster', 9 | sliderLabel: 'Afbeeldingen', 10 | slideLabel: 'Afbeelding' 11 | } 12 | -------------------------------------------------------------------------------- /src/scss/parvus.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | // Transition 3 | --parvus-transition-duration: 0.3s; 4 | --parvus-transition-timing-function: cubic-bezier(0.62, 0.16, 0.13, 1.01); 5 | 6 | // Overlay 7 | --parvus-background-color: hsl(23deg 44% 96%); 8 | --parvus-color: hsl(228deg 24% 23%); 9 | 10 | // Button 11 | --parvus-btn-background-color: hsl(228deg 24% 23%); 12 | --parvus-btn-color: hsl(0deg 0% 100%); 13 | --parvus-btn-hover-background-color: hsl(229deg 24% 33%); 14 | --parvus-btn-hover-color: hsl(0deg 0% 100%); 15 | --parvus-btn-disabled-background-color: hsla(229deg 24% 33% / 60%); 16 | --parvus-btn-disabled-color: hsl(0deg 0% 100%); 17 | 18 | // Caption 19 | --parvus-caption-background-color: transparent; 20 | --parvus-caption-color: hsl(228deg 24% 23%); 21 | 22 | // Loading error 23 | --parvus-loading-error-background-color: hsl(0deg 0% 100%); 24 | --parvus-loading-error-color: hsl(228deg 24% 23%); 25 | 26 | // Loader 27 | --parvus-loader-background-color: hsl(23deg 40% 96%); 28 | --parvus-loader-color: hsl(228deg 24% 23%); 29 | } 30 | 31 | ::view-transition-group(lightboximage) { 32 | animation-duration: var(--parvus-transition-duration); 33 | animation-timing-function: var(--parvus-transition-timing-function); 34 | z-index: 7; 35 | } 36 | 37 | ::view-transition-group(toolbar) { 38 | z-index: 8; 39 | } 40 | 41 | body:has(.parvus[open]) { 42 | touch-action: none; 43 | } 44 | 45 | /** 46 | * Parvus trigger 47 | * 48 | */ 49 | .parvus-trigger:has(img) { 50 | display: block; 51 | position: relative; 52 | 53 | 54 | & .parvus-zoom__indicator { 55 | align-items: center; 56 | background-color: var(--parvus-btn-background-color); 57 | color: var(--parvus-btn-color); 58 | display: flex; 59 | justify-content: center; 60 | padding: 0.5rem; 61 | position: absolute; 62 | inset-inline-end: 0.5rem; 63 | inset-block-start: 0.5rem; 64 | } 65 | 66 | & img { 67 | display: block; 68 | } 69 | } 70 | 71 | /** 72 | * Parvus 73 | * 74 | */ 75 | .parvus { 76 | background-color: transparent; 77 | block-size: 100%; 78 | border: 0; 79 | box-sizing: border-box; 80 | color: var(--parvus-color); 81 | contain: strict; 82 | inline-size: 100%; 83 | inset: 0; 84 | margin: 0; 85 | max-block-size: unset; 86 | max-inline-size: unset; 87 | overflow: hidden; 88 | overscroll-behavior: contain; 89 | padding: 0; 90 | position: fixed; 91 | 92 | &::backdrop { 93 | display:none; 94 | } 95 | 96 | & *, 97 | & *::before, 98 | & *::after { 99 | box-sizing: border-box; 100 | } 101 | 102 | &__overlay { 103 | background-color: var(--parvus-background-color); 104 | color: var(--parvus-color); 105 | inset: 0; 106 | position: absolute; 107 | } 108 | 109 | &__slider { 110 | inset: 0; 111 | position: absolute; 112 | transform: translateZ(0); 113 | 114 | @media screen and (prefers-reduced-motion: no-preference) { 115 | 116 | &--animate:not(&--is-dragging) { 117 | transition: transform var(--parvus-transition-duration) var(--parvus-transition-timing-function); 118 | will-change: transform; 119 | } 120 | } 121 | 122 | &--is-draggable { 123 | cursor: grab; 124 | touch-action: pan-y pinch-zoom; 125 | } 126 | 127 | &--is-dragging { 128 | cursor: grabbing; 129 | touch-action: none; 130 | } 131 | } 132 | 133 | &__slide { 134 | block-size: 100%; 135 | contain: layout; 136 | display: grid; 137 | inline-size: 100%; 138 | padding-block: 1rem; 139 | padding-inline: 1rem; 140 | place-items: center; 141 | 142 | 143 | & img { 144 | block-size: auto; 145 | display: block; 146 | inline-size: auto; 147 | margin-inline: auto; 148 | transform: translateZ(0); 149 | } 150 | } 151 | 152 | &__content { 153 | 154 | 155 | 156 | &--error { 157 | background-color: var(--parvus-loading-error-background-color); 158 | color: var(--parvus-loading-error-color); 159 | padding-block: 0.5rem; 160 | padding-inline: 1rem; 161 | } 162 | } 163 | 164 | &__caption { 165 | background-color: var(--parvus-caption-background-color); 166 | color: var(--parvus-caption-color); 167 | padding-block-start: 0.5rem; 168 | text-align: start; 169 | } 170 | 171 | &__loader { 172 | display: inline-block; 173 | block-size: 6.25rem; 174 | inset-inline-start: 50%; 175 | position: absolute; 176 | inset-block-start: 50%; 177 | transform: translate(-50%, -50%); 178 | inline-size: 6.25rem; 179 | 180 | &::before { 181 | animation: spin 1s infinite linear; 182 | border-radius: 100%; 183 | border: 0.25rem solid var(--parvus-loader-background-color); 184 | border-block-start-color: var(--parvus-loader-color); 185 | content: ''; 186 | inset: 0; 187 | position: absolute; 188 | z-index: 1; 189 | } 190 | } 191 | 192 | &__toolbar { 193 | align-items: center; 194 | display: flex; 195 | inset-block-start: 1rem; 196 | inset-inline: 1rem; 197 | justify-content: space-between; 198 | pointer-events: none; 199 | position: absolute; 200 | view-transition-name: toolbar; 201 | z-index: 8; 202 | 203 | 204 | & > * { 205 | pointer-events: auto; 206 | } 207 | } 208 | 209 | &__controls { 210 | display: flex; 211 | gap: 0.5rem; 212 | } 213 | 214 | &__btn { 215 | appearance: none; 216 | background-color: var(--parvus-btn-background-color); 217 | background-image: none; 218 | border-radius: 0; 219 | border: 0.0625rem solid transparent; 220 | color: var(--parvus-btn-color); 221 | cursor: pointer; 222 | display: flex; 223 | font: inherit; 224 | padding: 0.3125rem; 225 | position: relative; 226 | touch-action: manipulation; 227 | will-change: transform, opacity; 228 | z-index: 7; 229 | 230 | &:hover, 231 | &:focus-visible { 232 | background-color: var(--parvus-btn-hover-background-color); 233 | color: var(--parvus-btn-hover-color); 234 | } 235 | 236 | 237 | &--previous { 238 | inset-inline-start: 0; 239 | position: absolute; 240 | inset-block-start: calc(50svh - 1rem); // 50svh - paddingTop from .parvus__slide 241 | transform: translateY(-50%); 242 | } 243 | 244 | &--next { 245 | position: absolute; 246 | inset-inline-end: 0; 247 | inset-block-start: calc(50svh - 1rem); // 50svh - paddingTop from .parvus__slide 248 | transform: translateY(-50%); 249 | } 250 | 251 | & svg { 252 | pointer-events: none; 253 | } 254 | 255 | &[aria-hidden='true'] { 256 | display: none; 257 | } 258 | 259 | &[aria-disabled='true'] { 260 | background-color: var(--parvus-btn-disabled-background-color); 261 | color: var(--parvus-btn-disabled-color); 262 | } 263 | } 264 | 265 | &__counter { 266 | position: relative; 267 | z-index: 7; 268 | 269 | &[aria-hidden='true'] { 270 | display: none; 271 | } 272 | } 273 | 274 | @media screen and (prefers-reduced-motion: no-preference) { 275 | 276 | &__overlay, 277 | &__counter, 278 | &__btn--close, 279 | &__btn--previous, 280 | &__btn--next, 281 | &__caption { 282 | transition: transform var(--parvus-transition-duration) var(--parvus-transition-timing-function), opacity var(--parvus-transition-duration) var(--parvus-transition-timing-function); 283 | will-change: transform, opacity; 284 | } 285 | 286 | &--is-opening, 287 | &--is-closing { 288 | 289 | 290 | 291 | & .parvus__overlay, 292 | & .parvus__counter, 293 | & .parvus__btn--close, 294 | & .parvus__btn--previous, 295 | & .parvus__btn--next, 296 | & .parvus__caption { 297 | opacity: 0; 298 | } 299 | } 300 | 301 | &--is-vertical-closing, 302 | &--is-zooming { 303 | 304 | 305 | 306 | & .parvus__counter, 307 | & .parvus__btn--close { 308 | transform: translateY(-100%); 309 | opacity: 0; 310 | } 311 | 312 | & .parvus__btn--previous { 313 | transform: translate(-100%, -50%); 314 | opacity: 0; 315 | } 316 | 317 | & .parvus__btn--next { 318 | transform: translate(100%, -50%); 319 | opacity: 0; 320 | } 321 | 322 | & .parvus__caption { 323 | transform: translateY(100%); 324 | opacity: 0; 325 | } 326 | } 327 | } 328 | } 329 | 330 | @keyframes spin { 331 | 332 | from { 333 | transform: rotate(0deg); 334 | } 335 | 336 | to { 337 | transform: rotate(360deg); 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /test/images/1-1000.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/1-1000.webp -------------------------------------------------------------------------------- /test/images/1-1200.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/1-1200.webp -------------------------------------------------------------------------------- /test/images/1-370.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/1-370.webp -------------------------------------------------------------------------------- /test/images/1-500.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/1-500.webp -------------------------------------------------------------------------------- /test/images/1-700.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/1-700.webp -------------------------------------------------------------------------------- /test/images/2-1000.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/2-1000.webp -------------------------------------------------------------------------------- /test/images/2-1200.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/2-1200.webp -------------------------------------------------------------------------------- /test/images/2-370.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/2-370.webp -------------------------------------------------------------------------------- /test/images/2-500.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/2-500.webp -------------------------------------------------------------------------------- /test/images/2-700.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/2-700.webp -------------------------------------------------------------------------------- /test/images/3-1000.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/3-1000.webp -------------------------------------------------------------------------------- /test/images/3-1200.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/3-1200.webp -------------------------------------------------------------------------------- /test/images/3-370.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/3-370.webp -------------------------------------------------------------------------------- /test/images/3-500.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/3-500.webp -------------------------------------------------------------------------------- /test/images/3-700.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/3-700.webp -------------------------------------------------------------------------------- /test/images/4-1000.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/4-1000.webp -------------------------------------------------------------------------------- /test/images/4-1200.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/4-1200.webp -------------------------------------------------------------------------------- /test/images/4-370.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/4-370.webp -------------------------------------------------------------------------------- /test/images/4-500.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/4-500.webp -------------------------------------------------------------------------------- /test/images/4-700.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/4-700.webp -------------------------------------------------------------------------------- /test/images/8-1000.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/8-1000.webp -------------------------------------------------------------------------------- /test/images/8-1200.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/8-1200.webp -------------------------------------------------------------------------------- /test/images/8-370.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/8-370.webp -------------------------------------------------------------------------------- /test/images/8-500.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/8-500.webp -------------------------------------------------------------------------------- /test/images/8-700.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/8-700.webp -------------------------------------------------------------------------------- /test/images/9-1000.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/9-1000.webp -------------------------------------------------------------------------------- /test/images/9-1200.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/9-1200.webp -------------------------------------------------------------------------------- /test/images/9-370.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/9-370.webp -------------------------------------------------------------------------------- /test/images/9-500.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/9-500.webp -------------------------------------------------------------------------------- /test/images/9-700.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deoostfrees/Parvus/1fd9a326c576b027d32a441f160920cf2ed05447/test/images/9-700.webp -------------------------------------------------------------------------------- /test/test.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Set custom Parvus styles 3 | * 4 | */ 5 | .parvus { 6 | --parvus-background-color: hsl(23deg 40% 96%); 7 | } 8 | 9 | .parvus__overlay { 10 | opacity: 0.94; 11 | } 12 | 13 | /** 14 | * Only for demo 15 | * 16 | */ 17 | * { 18 | margin: 0; 19 | padding: 0; 20 | } 21 | 22 | *, 23 | *::before, 24 | *::after { 25 | box-sizing: border-box; 26 | } 27 | 28 | html { 29 | font: normal normal 400 100%/1.65 -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; 30 | } 31 | 32 | body { 33 | background-color: #fff; 34 | color: #404040; 35 | padding-block: calc((24 / 16) * 1rem); 36 | } 37 | 38 | .container { 39 | 40 | 41 | 42 | & + & { 43 | margin-block-start: calc((24 / 16) * 1rem); 44 | } 45 | } 46 | 47 | .padding-top-m { 48 | padding-block-start: calc((16 / 16) * 1rem); 49 | } 50 | 51 | h1 { 52 | margin-block-end: calc((16 / 16) * 1rem); 53 | } 54 | 55 | h2 { 56 | margin-block-end: calc((16 / 16) * 1rem); 57 | } 58 | 59 | p { 60 | max-inline-size: 67ch; 61 | } 62 | 63 | img { 64 | block-size: auto; 65 | display: block; 66 | inline-size: 100%; 67 | max-inline-size: 100%; 68 | } 69 | 70 | code { 71 | background-color: #f3f4f4; 72 | font-size: calc((16 / 16) * 1rem); 73 | line-height: 1.75; 74 | padding-block: calc((3 / 16) * 1rem); 75 | padding-inline: calc((6 / 16) * 1rem); 76 | } 77 | 78 | .event { 79 | background-color: #00f; 80 | color: #fff; 81 | inset-block-end: calc((16 / 16) * 1rem); 82 | inset-inline-start: calc((16 / 16) * 1rem); 83 | padding-block: calc((8 / 16) * 1rem); 84 | padding-inline: calc((16 / 16) * 1rem); 85 | position: fixed; 86 | z-index: 9999; 87 | } 88 | 89 | :focus-visible { 90 | outline: calc((2 / 16) * 1rem) dashed blue; 91 | outline-offset: calc((2 / 16) * 1rem); 92 | } 93 | -------------------------------------------------------------------------------- /test/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |Overlays suck, but if you need one, consider using Parvus. Parvus is an open source, dependency free image lightbox with the goal of being accessible.
18 |Gallery with the data-group
attribute.
Text links with the data-group
attribute.
Gallery with the gallerySelector
option.