├── .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 | ![Screenshot of Parvus. It shows the first picture of a gallery.](https://rqrauhvmra.com/parvus/parvus.png) 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 | Page title 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | ``` 56 | 57 | ### Package Managers 58 | 59 | You can also install Parvus using npm or yarn: 60 | 61 | ```sh 62 | npm install parvus 63 | ``` 64 | 65 | or 66 | 67 | ```sh 68 | yarn add parvus 69 | ``` 70 | 71 | After installation, import Parvus into your JavaScript codebase: 72 | 73 | ```js 74 | import Parvus from 'parvus' 75 | ``` 76 | 77 | Be sure to include the corresponding SCSS or CSS file. 78 | 79 | ## Usage 80 | 81 | Link a thumbnail image with the class `lightbox` to a larger image: 82 | 83 | ```html 84 | 85 | 86 | 87 | ``` 88 | 89 | Initialize the script: 90 | 91 | ```js 92 | const prvs = new Parvus() 93 | ``` 94 | 95 | ### Captions 96 | 97 | To show a caption under the image, add a `data-caption` attribute: 98 | 99 | ```html 100 | 101 | 102 | 103 | ``` 104 | 105 | Alternatively, set the option `captionsSelector` to select captions from an element's innerHTML: 106 | 107 | ```html 108 | 109 |
110 | 111 | 112 |
113 |

I'm a caption

114 |
115 |
116 |
117 | ``` 118 | 119 | ```js 120 | const prvs = new Parvus({ 121 | captionsSelector: '.figure__caption', 122 | }) 123 | ``` 124 | 125 | ### Gallery 126 | 127 | To group related images into a set, add a `data-group` attribute: 128 | 129 | ```html 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | //... 139 | 140 | 141 | 142 | 143 | ``` 144 | 145 | Alternatively, set the option `gallerySelector` to group all images with a specific class within a selector: 146 | 147 | ```html 148 | 159 | ``` 160 | 161 | ```js 162 | const prvs = new Parvus({ 163 | gallerySelector: '.gallery', 164 | }) 165 | ``` 166 | 167 | ### Responsive Images 168 | 169 | Specify different image sources and sizes using the `data-srcset` and `data-sizes` attributes: 170 | 171 | ```html 172 | 181 | 182 | 183 | ``` 184 | 185 | ### Localization 186 | 187 | Import the language module and set it as an option for localization: 188 | 189 | ```js 190 | import de from 'parvus/src/l10n/de' 191 | 192 | const prvs = new Parvus({ 193 | l10n: de 194 | }) 195 | ``` 196 | 197 | ## Options 198 | 199 | Customize Parvus by passing an options object when initializing: 200 | 201 | ```js 202 | const prvs = new Parvus({ 203 | // Clicking outside does not close Parvus 204 | docClose: false 205 | }) 206 | ``` 207 | 208 | Available options include: 209 | 210 | ```js 211 | { 212 | // Selector for elements that trigger Parvus 213 | selector: '.lightbox', 214 | 215 | // Selector for a group of elements combined as a gallery, overrides the `data-group` attribute. 216 | gallerySelector: null, 217 | 218 | // Display zoom indicator 219 | zoomIndicator: true, 220 | 221 | // Display captions if available 222 | captions: true, 223 | 224 | // Selector for the element where the caption is displayed; use "self" for the `a` tag itself. 225 | captionsSelector: 'self', 226 | 227 | // Attribute to get the caption from 228 | captionsAttribute: 'data-caption', 229 | 230 | // Clicking outside closes Parvus 231 | docClose: true, 232 | 233 | // Close Parvus by swiping up/down 234 | swipeClose: true, 235 | 236 | // Accept mouse events like touch events (click and drag to change slides) 237 | simulateTouch: true, 238 | 239 | // Touch dragging threshold in pixels 240 | threshold: 100, 241 | 242 | // Hide browser scrollbar 243 | hideScrollbar: true, 244 | 245 | // Icons 246 | lightboxIndicatorIcon: '', 247 | previousButtonIcon: '', 248 | nextButtonIcon: '', 249 | closeButtonIcon: '', 250 | 251 | // Localization of strings 252 | l10n: en 253 | } 254 | ``` 255 | 256 | ## API 257 | 258 | Parvus provides the following API functions: 259 | 260 | | Function | Description | 261 | | --- | --- | 262 | | `open(element)` | Open the specified `element` (DOM element) | 263 | | `close()` | Close Parvus | 264 | | `previous()` | Show the previous image | 265 | | `next()` | Show the next image | 266 | | `select(index)` | Select a slide with the specified `index` (integer) | 267 | | `add(element)` | Add the specified `element` (DOM element) | 268 | | `remove(element)` | Remove the specified `element` (DOM element) | 269 | | `destroy()` | Destroy Parvus | 270 | | `isOpen()` | Check if Parvus is currently open | 271 | | `currentIndex()` | Get the index of the currently displayed slide | 272 | 273 | ## Events 274 | 275 | Bind and unbind events using the `.on()` and `.off()` methods: 276 | 277 | ```js 278 | const prvs = new Parvus() 279 | 280 | const listener = () => { 281 | console.log('eventName happened') 282 | } 283 | 284 | // Bind event listener 285 | prvs.on(eventName, listener) 286 | 287 | // Unbind event listener 288 | prvs.off(eventName, listener) 289 | ``` 290 | 291 | Available events: 292 | 293 | | eventName | Description | 294 | | --- | --- | 295 | | `open` | Triggered after Parvus has opened | 296 | | `select` | Triggered when a slide is selected | 297 | | `close` | Triggered after Parvus has closed | 298 | | `destroy` | Triggered after Parvus has destroyed | 299 | 300 | ## Browser Support 301 | 302 | Parvus is supported on the latest versions of the following browsers: 303 | 304 | - Chrome 305 | - Edge 306 | - Firefox 307 | - Safari 308 | -------------------------------------------------------------------------------- /dist/css/parvus.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --parvus-transition-duration: 0.3s; 3 | --parvus-transition-timing-function: cubic-bezier(0.62, 0.16, 0.13, 1.01); 4 | --parvus-background-color: hsl(23deg 44% 96%); 5 | --parvus-color: hsl(228deg 24% 23%); 6 | --parvus-btn-background-color: hsl(228deg 24% 23%); 7 | --parvus-btn-color: hsl(0deg 0% 100%); 8 | --parvus-btn-hover-background-color: hsl(229deg 24% 33%); 9 | --parvus-btn-hover-color: hsl(0deg 0% 100%); 10 | --parvus-btn-disabled-background-color: hsla(229deg 24% 33% / 60%); 11 | --parvus-btn-disabled-color: hsl(0deg 0% 100%); 12 | --parvus-caption-background-color: transparent; 13 | --parvus-caption-color: hsl(228deg 24% 23%); 14 | --parvus-loading-error-background-color: hsl(0deg 0% 100%); 15 | --parvus-loading-error-color: hsl(228deg 24% 23%); 16 | --parvus-loader-background-color: hsl(23deg 40% 96%); 17 | --parvus-loader-color: hsl(228deg 24% 23%); 18 | } 19 | 20 | ::view-transition-group(lightboximage) { 21 | animation-duration: var(--parvus-transition-duration); 22 | animation-timing-function: var(--parvus-transition-timing-function); 23 | z-index: 7; 24 | } 25 | 26 | ::view-transition-group(toolbar) { 27 | z-index: 8; 28 | } 29 | 30 | body:has(.parvus[open]) { 31 | touch-action: none; 32 | } 33 | 34 | /** 35 | * Parvus trigger 36 | * 37 | */ 38 | .parvus-trigger:has(img) { 39 | display: block; 40 | position: relative; 41 | } 42 | .parvus-trigger:has(img) .parvus-zoom__indicator { 43 | align-items: center; 44 | background-color: var(--parvus-btn-background-color); 45 | color: var(--parvus-btn-color); 46 | display: flex; 47 | justify-content: center; 48 | padding: 0.5rem; 49 | position: absolute; 50 | inset-inline-end: 0.5rem; 51 | inset-block-start: 0.5rem; 52 | } 53 | .parvus-trigger:has(img) img { 54 | display: block; 55 | } 56 | 57 | /** 58 | * Parvus 59 | * 60 | */ 61 | .parvus { 62 | background-color: transparent; 63 | block-size: 100%; 64 | border: 0; 65 | box-sizing: border-box; 66 | color: var(--parvus-color); 67 | contain: strict; 68 | inline-size: 100%; 69 | inset: 0; 70 | margin: 0; 71 | max-block-size: unset; 72 | max-inline-size: unset; 73 | overflow: hidden; 74 | overscroll-behavior: contain; 75 | padding: 0; 76 | position: fixed; 77 | } 78 | .parvus::backdrop { 79 | display: none; 80 | } 81 | .parvus *, .parvus *::before, .parvus *::after { 82 | box-sizing: border-box; 83 | } 84 | .parvus__overlay { 85 | background-color: var(--parvus-background-color); 86 | color: var(--parvus-color); 87 | inset: 0; 88 | position: absolute; 89 | } 90 | .parvus__slider { 91 | inset: 0; 92 | position: absolute; 93 | transform: translateZ(0); 94 | } 95 | @media screen and (prefers-reduced-motion: no-preference) { 96 | .parvus__slider--animate:not(.parvus__slider--is-dragging) { 97 | transition: transform var(--parvus-transition-duration) var(--parvus-transition-timing-function); 98 | will-change: transform; 99 | } 100 | } 101 | .parvus__slider--is-draggable { 102 | cursor: grab; 103 | touch-action: pan-y pinch-zoom; 104 | } 105 | .parvus__slider--is-dragging { 106 | cursor: grabbing; 107 | touch-action: none; 108 | } 109 | .parvus__slide { 110 | block-size: 100%; 111 | contain: layout; 112 | display: grid; 113 | inline-size: 100%; 114 | padding-block: 1rem; 115 | padding-inline: 1rem; 116 | place-items: center; 117 | } 118 | .parvus__slide img { 119 | block-size: auto; 120 | display: block; 121 | inline-size: auto; 122 | margin-inline: auto; 123 | transform: translateZ(0); 124 | } 125 | .parvus__content--error { 126 | background-color: var(--parvus-loading-error-background-color); 127 | color: var(--parvus-loading-error-color); 128 | padding-block: 0.5rem; 129 | padding-inline: 1rem; 130 | } 131 | .parvus__caption { 132 | background-color: var(--parvus-caption-background-color); 133 | color: var(--parvus-caption-color); 134 | padding-block-start: 0.5rem; 135 | text-align: start; 136 | } 137 | .parvus__loader { 138 | display: inline-block; 139 | block-size: 6.25rem; 140 | inset-inline-start: 50%; 141 | position: absolute; 142 | inset-block-start: 50%; 143 | transform: translate(-50%, -50%); 144 | inline-size: 6.25rem; 145 | } 146 | .parvus__loader::before { 147 | animation: spin 1s infinite linear; 148 | border-radius: 100%; 149 | border: 0.25rem solid var(--parvus-loader-background-color); 150 | border-block-start-color: var(--parvus-loader-color); 151 | content: ""; 152 | inset: 0; 153 | position: absolute; 154 | z-index: 1; 155 | } 156 | .parvus__toolbar { 157 | align-items: center; 158 | display: flex; 159 | inset-block-start: 1rem; 160 | inset-inline: 1rem; 161 | justify-content: space-between; 162 | pointer-events: none; 163 | position: absolute; 164 | view-transition-name: toolbar; 165 | z-index: 8; 166 | } 167 | .parvus__toolbar > * { 168 | pointer-events: auto; 169 | } 170 | .parvus__controls { 171 | display: flex; 172 | gap: 0.5rem; 173 | } 174 | .parvus__btn { 175 | appearance: none; 176 | background-color: var(--parvus-btn-background-color); 177 | background-image: none; 178 | border-radius: 0; 179 | border: 0.0625rem solid transparent; 180 | color: var(--parvus-btn-color); 181 | cursor: pointer; 182 | display: flex; 183 | font: inherit; 184 | padding: 0.3125rem; 185 | position: relative; 186 | touch-action: manipulation; 187 | will-change: transform, opacity; 188 | z-index: 7; 189 | } 190 | .parvus__btn:hover, .parvus__btn:focus-visible { 191 | background-color: var(--parvus-btn-hover-background-color); 192 | color: var(--parvus-btn-hover-color); 193 | } 194 | .parvus__btn--previous { 195 | inset-inline-start: 0; 196 | position: absolute; 197 | inset-block-start: calc(50svh - 1rem); 198 | transform: translateY(-50%); 199 | } 200 | .parvus__btn--next { 201 | position: absolute; 202 | inset-inline-end: 0; 203 | inset-block-start: calc(50svh - 1rem); 204 | transform: translateY(-50%); 205 | } 206 | .parvus__btn svg { 207 | pointer-events: none; 208 | } 209 | .parvus__btn[aria-hidden=true] { 210 | display: none; 211 | } 212 | .parvus__btn[aria-disabled=true] { 213 | background-color: var(--parvus-btn-disabled-background-color); 214 | color: var(--parvus-btn-disabled-color); 215 | } 216 | .parvus__counter { 217 | position: relative; 218 | z-index: 7; 219 | } 220 | .parvus__counter[aria-hidden=true] { 221 | display: none; 222 | } 223 | @media screen and (prefers-reduced-motion: no-preference) { 224 | .parvus__overlay, .parvus__counter, .parvus__btn--close, .parvus__btn--previous, .parvus__btn--next, .parvus__caption { 225 | transition: transform var(--parvus-transition-duration) var(--parvus-transition-timing-function), opacity var(--parvus-transition-duration) var(--parvus-transition-timing-function); 226 | will-change: transform, opacity; 227 | } 228 | .parvus--is-opening .parvus__overlay, .parvus--is-opening .parvus__counter, .parvus--is-opening .parvus__btn--close, .parvus--is-opening .parvus__btn--previous, .parvus--is-opening .parvus__btn--next, .parvus--is-opening .parvus__caption, .parvus--is-closing .parvus__overlay, .parvus--is-closing .parvus__counter, .parvus--is-closing .parvus__btn--close, .parvus--is-closing .parvus__btn--previous, .parvus--is-closing .parvus__btn--next, .parvus--is-closing .parvus__caption { 229 | opacity: 0; 230 | } 231 | .parvus--is-vertical-closing .parvus__counter, .parvus--is-vertical-closing .parvus__btn--close, .parvus--is-zooming .parvus__counter, .parvus--is-zooming .parvus__btn--close { 232 | transform: translateY(-100%); 233 | opacity: 0; 234 | } 235 | .parvus--is-vertical-closing .parvus__btn--previous, .parvus--is-zooming .parvus__btn--previous { 236 | transform: translate(-100%, -50%); 237 | opacity: 0; 238 | } 239 | .parvus--is-vertical-closing .parvus__btn--next, .parvus--is-zooming .parvus__btn--next { 240 | transform: translate(100%, -50%); 241 | opacity: 0; 242 | } 243 | .parvus--is-vertical-closing .parvus__caption, .parvus--is-zooming .parvus__caption { 244 | transform: translateY(100%); 245 | opacity: 0; 246 | } 247 | } 248 | 249 | @keyframes spin { 250 | from { 251 | transform: rotate(0deg); 252 | } 253 | to { 254 | transform: rotate(360deg); 255 | } 256 | } -------------------------------------------------------------------------------- /dist/css/parvus.min.css: -------------------------------------------------------------------------------- 1 | :root{--parvus-transition-duration:0.3s;--parvus-transition-timing-function:cubic-bezier(0.62,0.16,0.13,1.01);--parvus-background-color:#f9f4f0;--parvus-color:#2d3249;--parvus-btn-background-color:#2d3249;--parvus-btn-color:#fff;--parvus-btn-hover-background-color:#404768;--parvus-btn-hover-color:#fff;--parvus-btn-disabled-background-color:rgba(64,71,104,.6);--parvus-btn-disabled-color:#fff;--parvus-caption-background-color:transparent;--parvus-caption-color:#2d3249;--parvus-loading-error-background-color:#fff;--parvus-loading-error-color:#2d3249;--parvus-loader-background-color:#f9f4f1;--parvus-loader-color:#2d3249}::view-transition-group(lightboximage){animation-duration:var(--parvus-transition-duration);animation-timing-function:var(--parvus-transition-timing-function);z-index:7}::view-transition-group(toolbar){z-index:8}body:has(.parvus[open]){touch-action:none}.parvus-trigger:has(img){display:block;position:relative}.parvus-trigger:has(img) .parvus-zoom__indicator{align-items:center;background-color:var(--parvus-btn-background-color);color:var(--parvus-btn-color);display:flex;inset-block-start:.5rem;inset-inline-end:.5rem;justify-content:center;padding:.5rem;position:absolute}.parvus-trigger:has(img) img{display:block}.parvus{background-color:transparent;block-size:100%;border:0;box-sizing:border-box;color:var(--parvus-color);contain:strict;inline-size:100%;inset:0;margin:0;max-block-size:unset;max-inline-size:unset;overflow:hidden;overscroll-behavior:contain;padding:0;position:fixed}.parvus::backdrop{display:none}.parvus *,.parvus :after,.parvus :before{box-sizing:border-box}.parvus__overlay{background-color:var(--parvus-background-color);color:var(--parvus-color);inset:0;position:absolute}.parvus__slider{inset:0;position:absolute;transform:translateZ(0)}@media screen and (prefers-reduced-motion:no-preference){.parvus__slider--animate:not(.parvus__slider--is-dragging){transition:transform var(--parvus-transition-duration) var(--parvus-transition-timing-function);will-change:transform}}.parvus__slider--is-draggable{cursor:grab;touch-action:pan-y pinch-zoom}.parvus__slider--is-dragging{cursor:grabbing;touch-action:none}.parvus__slide{block-size:100%;contain:layout;display:grid;inline-size:100%;padding-block:1rem;padding-inline:1rem;place-items:center}.parvus__slide img{block-size:auto;display:block;inline-size:auto;margin-inline:auto;transform:translateZ(0)}.parvus__content--error{background-color:var(--parvus-loading-error-background-color);color:var(--parvus-loading-error-color);padding-block:.5rem;padding-inline:1rem}.parvus__caption{background-color:var(--parvus-caption-background-color);color:var(--parvus-caption-color);padding-block-start:.5rem;text-align:start}.parvus__loader{block-size:6.25rem;display:inline-block;inline-size:6.25rem;inset-block-start:50%;inset-inline-start:50%;position:absolute;transform:translate(-50%,-50%)}.parvus__loader:before{animation:spin 1s linear infinite;border:.25rem solid var(--parvus-loader-background-color);border-block-start-color:var(--parvus-loader-color);border-radius:100%;content:"";inset:0;position:absolute;z-index:1}.parvus__toolbar{view-transition-name:toolbar;align-items:center;display:flex;inset-block-start:1rem;inset-inline:1rem;justify-content:space-between;pointer-events:none;position:absolute;z-index:8}.parvus__toolbar>*{pointer-events:auto}.parvus__controls{display:flex;gap:.5rem}.parvus__btn{appearance:none;background-color:var(--parvus-btn-background-color);background-image:none;border:.0625rem solid transparent;border-radius:0;color:var(--parvus-btn-color);cursor:pointer;display:flex;font:inherit;padding:.3125rem;position:relative;touch-action:manipulation;will-change:transform,opacity;z-index:7}.parvus__btn:focus-visible,.parvus__btn:hover{background-color:var(--parvus-btn-hover-background-color);color:var(--parvus-btn-hover-color)}.parvus__btn--previous{inset-inline-start:0}.parvus__btn--next,.parvus__btn--previous{inset-block-start:calc(50svh - 1rem);position:absolute;transform:translateY(-50%)}.parvus__btn--next{inset-inline-end:0}.parvus__btn svg{pointer-events:none}.parvus__btn[aria-hidden=true]{display:none}.parvus__btn[aria-disabled=true]{background-color:var(--parvus-btn-disabled-background-color);color:var(--parvus-btn-disabled-color)}.parvus__counter{position:relative;z-index:7}.parvus__counter[aria-hidden=true]{display:none}@media screen and (prefers-reduced-motion:no-preference){.parvus__btn--close,.parvus__btn--next,.parvus__btn--previous,.parvus__caption,.parvus__counter,.parvus__overlay{transition:transform var(--parvus-transition-duration) var(--parvus-transition-timing-function),opacity var(--parvus-transition-duration) var(--parvus-transition-timing-function);will-change:transform,opacity}.parvus--is-closing .parvus__btn--close,.parvus--is-closing .parvus__btn--next,.parvus--is-closing .parvus__btn--previous,.parvus--is-closing .parvus__caption,.parvus--is-closing .parvus__counter,.parvus--is-closing .parvus__overlay,.parvus--is-opening .parvus__btn--close,.parvus--is-opening .parvus__btn--next,.parvus--is-opening .parvus__btn--previous,.parvus--is-opening .parvus__caption,.parvus--is-opening .parvus__counter,.parvus--is-opening .parvus__overlay{opacity:0}.parvus--is-vertical-closing .parvus__btn--close,.parvus--is-vertical-closing .parvus__counter,.parvus--is-zooming .parvus__btn--close,.parvus--is-zooming .parvus__counter{opacity:0;transform:translateY(-100%)}.parvus--is-vertical-closing .parvus__btn--previous,.parvus--is-zooming .parvus__btn--previous{opacity:0;transform:translate(-100%,-50%)}.parvus--is-vertical-closing .parvus__btn--next,.parvus--is-zooming .parvus__btn--next{opacity:0;transform:translate(100%,-50%)}.parvus--is-vertical-closing .parvus__caption,.parvus--is-zooming .parvus__caption{opacity:0;transform:translateY(100%)}}@keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}} -------------------------------------------------------------------------------- /dist/js/parvus.esm.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 FOCUSABLE_ELEMENTS = ['a:not([inert]):not([tabindex^="-"])', 'button:not([inert]):not([tabindex^="-"]):not(:disabled)', '[tabindex]:not([inert]):not([tabindex^="-"])']; 12 | 13 | /** 14 | * Get the focusable children of the given element 15 | * 16 | * @return {Array} - An array of focusable children 17 | */ 18 | const getFocusableChildren = targetEl => { 19 | return Array.from(targetEl.querySelectorAll(FOCUSABLE_ELEMENTS.join(', '))).filter(child => child.offsetParent !== null); 20 | }; 21 | 22 | const BROWSER_WINDOW = window; 23 | 24 | /** 25 | * Get scrollbar width 26 | * 27 | * @return {Number} - The scrollbar width 28 | */ 29 | const getScrollbarWidth = () => { 30 | return BROWSER_WINDOW.innerWidth - document.documentElement.clientWidth; 31 | }; 32 | 33 | /** 34 | * Add zoom indicator to element 35 | * 36 | * @param {HTMLElement} el - The element to add the zoom indicator to 37 | * @param {Object} config - Options object 38 | */ 39 | const addZoomIndicator = (el, config) => { 40 | if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') === null) { 41 | const LIGHTBOX_INDICATOR_ICON = document.createElement('div'); 42 | LIGHTBOX_INDICATOR_ICON.className = 'parvus-zoom__indicator'; 43 | LIGHTBOX_INDICATOR_ICON.innerHTML = config.lightboxIndicatorIcon; 44 | el.appendChild(LIGHTBOX_INDICATOR_ICON); 45 | } 46 | }; 47 | 48 | /** 49 | * Remove zoom indicator for element 50 | * 51 | * @param {HTMLElement} el - The element to remove the zoom indicator to 52 | */ 53 | const removeZoomIndicator = el => { 54 | if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') !== null) { 55 | const LIGHTBOX_INDICATOR_ICON = el.querySelector('.parvus-zoom__indicator'); 56 | el.removeChild(LIGHTBOX_INDICATOR_ICON); 57 | } 58 | }; 59 | 60 | var en = { 61 | 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.', 62 | lightboxLoadingIndicatorLabel: 'Image loading', 63 | lightboxLoadingError: 'The requested image cannot be loaded.', 64 | controlsLabel: 'Controls', 65 | previousButtonLabel: 'Previous image', 66 | nextButtonLabel: 'Next image', 67 | closeButtonLabel: 'Close dialog window', 68 | sliderLabel: 'Images', 69 | slideLabel: 'Image' 70 | }; 71 | 72 | /** 73 | * Parvus Lightbox 74 | * 75 | * @param {Object} userOptions - User configuration options 76 | * @returns {Object} Parvus instance 77 | */ 78 | function Parvus(userOptions) { 79 | const BROWSER_WINDOW = window; 80 | const GROUP_ATTRIBUTES = { 81 | triggerElements: [], 82 | slider: null, 83 | sliderElements: [], 84 | contentElements: [] 85 | }; 86 | const GROUPS = {}; 87 | const activePointers = new Map(); 88 | let groupIdCounter = 0; 89 | let newGroup = null; 90 | let activeGroup = null; 91 | let currentIndex = 0; 92 | let config = {}; 93 | let lightbox = null; 94 | let lightboxOverlay = null; 95 | let lightboxOverlayOpacity = 1; 96 | let toolbar = null; 97 | let toolbarLeft = null; 98 | let toolbarRight = null; 99 | let controls = null; 100 | let previousButton = null; 101 | let nextButton = null; 102 | let closeButton = null; 103 | let counter = null; 104 | let drag = {}; 105 | let isDraggingX = false; 106 | let isDraggingY = false; 107 | let pointerDown = false; 108 | let currentScale = 1; 109 | let isPinching = false; 110 | let isTap = false; 111 | let pinchStartDistance = 0; 112 | let lastPointersId = null; 113 | let offset = null; 114 | let offsetTmp = null; 115 | let resizeTicking = false; 116 | let isReducedMotion = true; 117 | 118 | /** 119 | * Merge default options with user-provided options 120 | * 121 | * @param {Object} userOptions - User-provided options 122 | * @returns {Object} - Merged options object 123 | */ 124 | const mergeOptions = userOptions => { 125 | // Default options 126 | const DEFAULT_OPTIONS = { 127 | selector: '.lightbox', 128 | gallerySelector: null, 129 | zoomIndicator: true, 130 | captions: true, 131 | captionsSelector: 'self', 132 | captionsAttribute: 'data-caption', 133 | docClose: true, 134 | swipeClose: true, 135 | simulateTouch: true, 136 | threshold: 50, 137 | hideScrollbar: true, 138 | lightboxIndicatorIcon: '', 139 | previousButtonIcon: '', 140 | nextButtonIcon: '', 141 | closeButtonIcon: '', 142 | l10n: en 143 | }; 144 | const MERGED_OPTIONS = { 145 | ...DEFAULT_OPTIONS, 146 | ...userOptions 147 | }; 148 | if (userOptions && userOptions.l10n) { 149 | MERGED_OPTIONS.l10n = { 150 | ...DEFAULT_OPTIONS.l10n, 151 | ...userOptions.l10n 152 | }; 153 | } 154 | return MERGED_OPTIONS; 155 | }; 156 | 157 | /** 158 | * Check prefers reduced motion 159 | * https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList 160 | * 161 | */ 162 | const MOTIONQUERY = BROWSER_WINDOW.matchMedia('(prefers-reduced-motion)'); 163 | const reducedMotionCheck = () => { 164 | if (MOTIONQUERY.matches) { 165 | isReducedMotion = true; 166 | } else { 167 | isReducedMotion = false; 168 | } 169 | }; 170 | 171 | /** 172 | * Retrieves or creates a group identifier for the given element 173 | * 174 | * @param {HTMLElement} el - DOM element to get or assign a group to 175 | * @returns {string} The group identifier associated with the element 176 | */ 177 | const getGroup = el => { 178 | // Return existing group identifier if already assigned 179 | if (el.dataset.group) { 180 | return el.dataset.group; 181 | } 182 | 183 | // Generate new unique group identifier using counter 184 | const EL_GROUP = `default-${groupIdCounter++}`; 185 | 186 | // Assign the new group identifier to element's dataset 187 | el.dataset.group = EL_GROUP; 188 | return EL_GROUP; 189 | }; 190 | 191 | /** 192 | * Add an element 193 | * 194 | * @param {HTMLElement} el - The element to be added 195 | */ 196 | const add = el => { 197 | // Check element type and attributes 198 | const IS_VALID_LINK = el.tagName === 'A' && el.hasAttribute('href'); 199 | const IS_VALID_BUTTON = el.tagName === 'BUTTON' && el.hasAttribute('data-target'); 200 | if (!IS_VALID_LINK && !IS_VALID_BUTTON) { 201 | 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.'); 202 | } 203 | 204 | // Check if the lightbox already exists 205 | if (!lightbox) { 206 | createLightbox(); 207 | } 208 | newGroup = getGroup(el); 209 | if (!GROUPS[newGroup]) { 210 | GROUPS[newGroup] = structuredClone(GROUP_ATTRIBUTES); 211 | } 212 | if (GROUPS[newGroup].triggerElements.includes(el)) { 213 | throw new Error('Ups, element already added.'); 214 | } 215 | GROUPS[newGroup].triggerElements.push(el); 216 | if (config.zoomIndicator) { 217 | addZoomIndicator(el, config); 218 | } 219 | el.classList.add('parvus-trigger'); 220 | el.addEventListener('click', triggerParvus); 221 | if (isOpen() && newGroup === activeGroup) { 222 | const EL_INDEX = GROUPS[newGroup].triggerElements.indexOf(el); 223 | createSlide(EL_INDEX); 224 | createImage(el, EL_INDEX, () => { 225 | loadImage(EL_INDEX); 226 | }); 227 | updateAttributes(); 228 | updateSliderNavigationStatus(); 229 | updateCounter(); 230 | } 231 | }; 232 | 233 | /** 234 | * Remove an element 235 | * 236 | * @param {HTMLElement} el - The element to be removed 237 | */ 238 | const remove = el => { 239 | if (!el || !el.hasAttribute('data-group')) { 240 | return; 241 | } 242 | const EL_GROUP = getGroup(el); 243 | const GROUP = GROUPS[EL_GROUP]; 244 | 245 | // Check if element exists 246 | if (!GROUP) { 247 | return; 248 | } 249 | const EL_INDEX = GROUP.triggerElements.indexOf(el); 250 | if (EL_INDEX === -1) { 251 | return; 252 | } 253 | const IS_CURRENT_EL = isOpen() && EL_GROUP === activeGroup && EL_INDEX === currentIndex; 254 | 255 | // Remove group data 256 | if (GROUP.contentElements[EL_INDEX]) { 257 | const content = GROUP.contentElements[EL_INDEX]; 258 | if (content.tagName === 'IMG') { 259 | content.src = ''; 260 | content.srcset = ''; 261 | } 262 | } 263 | 264 | // Remove DOM element 265 | const sliderElement = GROUP.sliderElements[EL_INDEX]; 266 | if (sliderElement && sliderElement.parentNode) { 267 | sliderElement.parentNode.removeChild(sliderElement); 268 | } 269 | 270 | // Remove all array elements 271 | GROUP.triggerElements.splice(EL_INDEX, 1); 272 | GROUP.sliderElements.splice(EL_INDEX, 1); 273 | GROUP.contentElements.splice(EL_INDEX, 1); 274 | if (config.zoomIndicator) { 275 | removeZoomIndicator(el); 276 | } 277 | if (isOpen() && EL_GROUP === activeGroup) { 278 | if (IS_CURRENT_EL) { 279 | if (GROUP.triggerElements.length === 0) { 280 | close(); 281 | } else if (currentIndex >= GROUP.triggerElements.length) { 282 | select(GROUP.triggerElements.length - 1); 283 | } else { 284 | updateAttributes(); 285 | updateSliderNavigationStatus(); 286 | updateCounter(); 287 | } 288 | } else if (EL_INDEX < currentIndex) { 289 | currentIndex--; 290 | updateAttributes(); 291 | updateSliderNavigationStatus(); 292 | updateCounter(); 293 | } else { 294 | updateAttributes(); 295 | updateSliderNavigationStatus(); 296 | updateCounter(); 297 | } 298 | } 299 | 300 | // Unbind click event handler 301 | el.removeEventListener('click', triggerParvus); 302 | el.classList.remove('parvus-trigger'); 303 | }; 304 | 305 | /** 306 | * Create the lightbox 307 | * 308 | */ 309 | const createLightbox = () => { 310 | // Use DocumentFragment to batch DOM operations 311 | const fragment = document.createDocumentFragment(); 312 | 313 | // Create the lightbox container 314 | lightbox = document.createElement('dialog'); 315 | lightbox.setAttribute('role', 'dialog'); 316 | lightbox.setAttribute('aria-modal', 'true'); 317 | lightbox.setAttribute('aria-label', config.l10n.lightboxLabel); 318 | lightbox.classList.add('parvus'); 319 | 320 | // Create the lightbox overlay container 321 | lightboxOverlay = document.createElement('div'); 322 | lightboxOverlay.classList.add('parvus__overlay'); 323 | 324 | // Create the toolbar 325 | toolbar = document.createElement('div'); 326 | toolbar.className = 'parvus__toolbar'; 327 | 328 | // Create the toolbar items 329 | toolbarLeft = document.createElement('div'); 330 | toolbarRight = document.createElement('div'); 331 | 332 | // Create the controls 333 | controls = document.createElement('div'); 334 | controls.className = 'parvus__controls'; 335 | controls.setAttribute('role', 'group'); 336 | controls.setAttribute('aria-label', config.l10n.controlsLabel); 337 | 338 | // Create the close button 339 | closeButton = document.createElement('button'); 340 | closeButton.className = 'parvus__btn parvus__btn--close'; 341 | closeButton.setAttribute('type', 'button'); 342 | closeButton.setAttribute('aria-label', config.l10n.closeButtonLabel); 343 | closeButton.innerHTML = config.closeButtonIcon; 344 | 345 | // Create the previous button 346 | previousButton = document.createElement('button'); 347 | previousButton.className = 'parvus__btn parvus__btn--previous'; 348 | previousButton.setAttribute('type', 'button'); 349 | previousButton.setAttribute('aria-label', config.l10n.previousButtonLabel); 350 | previousButton.innerHTML = config.previousButtonIcon; 351 | 352 | // Create the next button 353 | nextButton = document.createElement('button'); 354 | nextButton.className = 'parvus__btn parvus__btn--next'; 355 | nextButton.setAttribute('type', 'button'); 356 | nextButton.setAttribute('aria-label', config.l10n.nextButtonLabel); 357 | nextButton.innerHTML = config.nextButtonIcon; 358 | 359 | // Create the counter 360 | counter = document.createElement('div'); 361 | counter.className = 'parvus__counter'; 362 | 363 | // Add the control buttons to the controls 364 | controls.append(closeButton, previousButton, nextButton); 365 | 366 | // Add the counter to the left toolbar item 367 | toolbarLeft.appendChild(counter); 368 | 369 | // Add the controls to the right toolbar item 370 | toolbarRight.appendChild(controls); 371 | 372 | // Add the toolbar items to the toolbar 373 | toolbar.append(toolbarLeft, toolbarRight); 374 | 375 | // Add the overlay and the toolbar to the lightbox 376 | lightbox.append(lightboxOverlay, toolbar); 377 | fragment.appendChild(lightbox); 378 | 379 | // Add to document body 380 | document.body.appendChild(fragment); 381 | }; 382 | 383 | /** 384 | * Create a slider 385 | * 386 | */ 387 | const createSlider = () => { 388 | const SLIDER = document.createElement('div'); 389 | SLIDER.className = 'parvus__slider'; 390 | 391 | // Update the slider reference in GROUPS 392 | GROUPS[activeGroup].slider = SLIDER; 393 | 394 | // Add the slider to the lightbox container 395 | lightbox.appendChild(SLIDER); 396 | }; 397 | 398 | /** 399 | * Get next slide index 400 | * 401 | * @param {Number} curentIndex - Current slide index 402 | * @returns {number} Index of the next available slide or -1 if none found 403 | */ 404 | const getNextSlideIndex = currentIndex => { 405 | const SLIDE_ELEMENTS = GROUPS[activeGroup].sliderElements; 406 | const TOTAL_SLIDE_ELEMENTS = SLIDE_ELEMENTS.length; 407 | for (let i = currentIndex + 1; i < TOTAL_SLIDE_ELEMENTS; i++) { 408 | if (SLIDE_ELEMENTS[i] !== undefined) { 409 | return i; 410 | } 411 | } 412 | return -1; 413 | }; 414 | 415 | /** 416 | * Get previous slide index 417 | * 418 | * @param {number} currentIndex - Current slide index 419 | * @returns {number} Index of the previous available slide or -1 if no found 420 | */ 421 | const getPreviousSlideIndex = currentIndex => { 422 | const SLIDE_ELEMENTS = GROUPS[activeGroup].sliderElements; 423 | for (let i = currentIndex - 1; i >= 0; i--) { 424 | if (SLIDE_ELEMENTS[i] !== undefined) { 425 | return i; 426 | } 427 | } 428 | return -1; 429 | }; 430 | 431 | /** 432 | * Create a slide 433 | * 434 | * @param {Number} index - The index of the slide 435 | */ 436 | const createSlide = index => { 437 | if (GROUPS[activeGroup].sliderElements[index] !== undefined) { 438 | return; 439 | } 440 | const FRAGMENT = document.createDocumentFragment(); 441 | const SLIDE_ELEMENT = document.createElement('div'); 442 | const SLIDE_ELEMENT_CONTENT = document.createElement('div'); 443 | const GROUP = GROUPS[activeGroup]; 444 | const TOTAL_TRIGGER_ELEMENTS = GROUP.triggerElements.length; 445 | SLIDE_ELEMENT.className = 'parvus__slide'; 446 | SLIDE_ELEMENT.style.cssText = ` 447 | position: absolute; 448 | left: ${index * 100}%; 449 | `; 450 | SLIDE_ELEMENT.setAttribute('aria-hidden', 'true'); 451 | 452 | // Add accessibility attributes if gallery has multiple slides 453 | if (TOTAL_TRIGGER_ELEMENTS > 1) { 454 | SLIDE_ELEMENT.setAttribute('role', 'group'); 455 | SLIDE_ELEMENT.setAttribute('aria-label', `${config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`); 456 | } 457 | SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT); 458 | FRAGMENT.appendChild(SLIDE_ELEMENT); 459 | GROUP.sliderElements[index] = SLIDE_ELEMENT; 460 | 461 | // Insert the slide element based on index position 462 | if (index >= currentIndex) { 463 | // Insert the slide element after the current slide 464 | const NEXT_SLIDE_INDEX = getNextSlideIndex(index); 465 | if (NEXT_SLIDE_INDEX !== -1) { 466 | GROUP.sliderElements[NEXT_SLIDE_INDEX].before(SLIDE_ELEMENT); 467 | } else { 468 | GROUP.slider.appendChild(SLIDE_ELEMENT); 469 | } 470 | } else { 471 | // Insert the slide element before the current slide 472 | const PREVIOUS_SLIDE_INDEX = getPreviousSlideIndex(index); 473 | if (PREVIOUS_SLIDE_INDEX !== -1) { 474 | GROUP.sliderElements[PREVIOUS_SLIDE_INDEX].after(SLIDE_ELEMENT); 475 | } else { 476 | GROUP.slider.prepend(SLIDE_ELEMENT); 477 | } 478 | } 479 | }; 480 | 481 | /** 482 | * Open Parvus 483 | * 484 | * @param {HTMLElement} el 485 | */ 486 | const open = el => { 487 | if (!lightbox || !el || !el.classList.contains('parvus-trigger') || isOpen()) { 488 | return; 489 | } 490 | activeGroup = getGroup(el); 491 | const GROUP = GROUPS[activeGroup]; 492 | const EL_INDEX = GROUP.triggerElements.indexOf(el); 493 | if (EL_INDEX === -1) { 494 | throw new Error('Ups, element not found in group.'); 495 | } 496 | currentIndex = EL_INDEX; 497 | history.pushState({ 498 | parvus: 'close' 499 | }, 'Image', window.location.href); 500 | bindEvents(); 501 | if (config.hideScrollbar) { 502 | document.body.style.marginInlineEnd = `${getScrollbarWidth()}px`; 503 | document.body.style.overflow = 'hidden'; 504 | } 505 | lightbox.classList.add('parvus--is-opening'); 506 | lightbox.showModal(); 507 | createSlider(); 508 | createSlide(currentIndex); 509 | updateOffset(); 510 | updateAttributes(); 511 | updateSliderNavigationStatus(); 512 | updateCounter(); 513 | loadSlide(currentIndex); 514 | createImage(el, currentIndex, () => { 515 | loadImage(currentIndex, true); 516 | lightbox.classList.remove('parvus--is-opening'); 517 | GROUP.slider.classList.add('parvus__slider--animate'); 518 | }); 519 | preload(currentIndex + 1); 520 | preload(currentIndex - 1); 521 | 522 | // Create and dispatch a new event 523 | dispatchCustomEvent('open'); 524 | }; 525 | 526 | /** 527 | * Close Parvus 528 | * 529 | */ 530 | const close = () => { 531 | if (!isOpen()) { 532 | return; 533 | } 534 | const IMAGE = GROUPS[activeGroup].contentElements[currentIndex]; 535 | const THUMBNAIL = GROUPS[activeGroup].triggerElements[currentIndex]; 536 | unbindEvents(); 537 | clearDrag(); 538 | if (history.state?.parvus === 'close') { 539 | history.back(); 540 | } 541 | lightbox.classList.add('parvus--is-closing'); 542 | const transitionendHandler = () => { 543 | // Reset the image zoom (if ESC was pressed or went back in the browser history) 544 | // after the ViewTransition (otherwise it looks bad) 545 | if (isPinching) { 546 | resetZoom(IMAGE); 547 | } 548 | leaveSlide(currentIndex); 549 | lightbox.close(); 550 | lightbox.classList.remove('parvus--is-closing'); 551 | lightbox.classList.remove('parvus--is-vertical-closing'); 552 | GROUPS[activeGroup].slider.remove(); 553 | GROUPS[activeGroup].slider = null; 554 | GROUPS[activeGroup].sliderElements = []; 555 | GROUPS[activeGroup].contentElements = []; 556 | counter.removeAttribute('aria-hidden'); 557 | previousButton.removeAttribute('aria-hidden'); 558 | previousButton.removeAttribute('aria-disabled'); 559 | nextButton.removeAttribute('aria-hidden'); 560 | nextButton.removeAttribute('aria-disabled'); 561 | if (config.hideScrollbar) { 562 | document.body.style.marginInlineEnd = ''; 563 | document.body.style.overflow = ''; 564 | } 565 | }; 566 | if (IMAGE && IMAGE.tagName === 'IMG') { 567 | if (document.startViewTransition) { 568 | IMAGE.style.viewTransitionName = 'lightboximage'; 569 | const transition = document.startViewTransition(() => { 570 | IMAGE.style.opacity = '0'; 571 | IMAGE.style.viewTransitionName = null; 572 | THUMBNAIL.style.viewTransitionName = 'lightboximage'; 573 | }); 574 | transition.finished.finally(() => { 575 | transitionendHandler(); 576 | THUMBNAIL.style.viewTransitionName = null; 577 | }); 578 | } else { 579 | IMAGE.style.opacity = '0'; 580 | requestAnimationFrame(transitionendHandler); 581 | } 582 | } else { 583 | transitionendHandler(); 584 | } 585 | }; 586 | 587 | /** 588 | * Preload slide with the specified index 589 | * 590 | * @param {Number} index - The index of the slide to be preloaded 591 | */ 592 | const preload = index => { 593 | if (index < 0 || index >= GROUPS[activeGroup].triggerElements.length || GROUPS[activeGroup].sliderElements[index] !== undefined) { 594 | return; 595 | } 596 | createSlide(index); 597 | createImage(GROUPS[activeGroup].triggerElements[index], index, () => { 598 | loadImage(index); 599 | }); 600 | }; 601 | 602 | /** 603 | * Load slide with the specified index 604 | * 605 | * @param {Number} index - The index of the slide to be loaded 606 | */ 607 | const loadSlide = index => { 608 | GROUPS[activeGroup].sliderElements[index].setAttribute('aria-hidden', 'false'); 609 | }; 610 | 611 | /** 612 | * Add caption to the container element 613 | * 614 | * @param {HTMLElement} containerEl - The container element to which the caption will be added 615 | * @param {HTMLElement} imageEl - The image the caption is linked to 616 | * @param {HTMLElement} el - The trigger element associated with the caption 617 | * @param {Number} index - The index of the caption 618 | */ 619 | const addCaption = (containerEl, imageEl, el, index) => { 620 | const CAPTION_CONTAINER = document.createElement('div'); 621 | let captionData = null; 622 | CAPTION_CONTAINER.className = 'parvus__caption'; 623 | if (config.captionsSelector === 'self') { 624 | if (el.hasAttribute(config.captionsAttribute) && el.getAttribute(config.captionsAttribute) !== '') { 625 | captionData = el.getAttribute(config.captionsAttribute); 626 | } 627 | } else { 628 | const CAPTION_SELECTOR = el.querySelector(config.captionsSelector); 629 | if (CAPTION_SELECTOR !== null) { 630 | if (CAPTION_SELECTOR.hasAttribute(config.captionsAttribute) && CAPTION_SELECTOR.getAttribute(config.captionsAttribute) !== '') { 631 | captionData = CAPTION_SELECTOR.getAttribute(config.captionsAttribute); 632 | } else { 633 | captionData = CAPTION_SELECTOR.innerHTML; 634 | } 635 | } 636 | } 637 | if (captionData !== null) { 638 | const CAPTION_ID = `parvus__caption-${index}`; 639 | CAPTION_CONTAINER.id = CAPTION_ID; 640 | CAPTION_CONTAINER.innerHTML = `

${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{const t=l[c].sliderElements;for(let r=e-1;r>=0;r--)if(void 0!==t[r])return r;return-1})(e);-1!==t?s.sliderElements[t].after(r):s.slider.prepend(r)}},P=e=>{if(!g||!e||!e.classList.contains("parvus-trigger")||be())return;c=H(e);const t=l[c],n=t.triggerElements.indexOf(e);if(-1===n)throw new Error("Ups, element not found in group.");m=n,history.pushState({parvus:"close"},"Image",window.location.href),he(),p.hideScrollbar&&(document.body.style.marginInlineEnd=`${r()}px`,document.body.style.overflow="hidden"),g.classList.add("parvus--is-opening"),g.showModal(),(()=>{const e=document.createElement("div");e.className="parvus__slider",l[c].slider=e,g.appendChild(e)})(),j(m),ee(),se(),te(),re(),W(m),R(e,m,(()=>{U(m,!0),g.classList.remove("parvus--is-opening"),t.slider.classList.add("parvus__slider--animate")})),V(m+1),V(m-1),fe("open")},G=()=>{if(!be())return;const e=l[c].contentElements[m],t=l[c].triggerElements[m];ve(),ne(),"close"===history.state?.parvus&&history.back(),g.classList.add("parvus--is-closing");const r=()=>{k&&le(e),Z(m),g.close(),g.classList.remove("parvus--is-closing"),g.classList.remove("parvus--is-vertical-closing"),l[c].slider.remove(),l[c].slider=null,l[c].sliderElements=[],l[c].contentElements=[],_.removeAttribute("aria-hidden"),A.removeAttribute("aria-hidden"),A.removeAttribute("aria-disabled"),w.removeAttribute("aria-hidden"),w.removeAttribute("aria-disabled"),p.hideScrollbar&&(document.body.style.marginInlineEnd="",document.body.style.overflow="")};if(e&&"IMG"===e.tagName)if(document.startViewTransition){e.style.viewTransitionName="lightboximage";document.startViewTransition((()=>{e.style.opacity="0",e.style.viewTransitionName=null,t.style.viewTransitionName="lightboximage"})).finished.finally((()=>{r(),t.style.viewTransitionName=null}))}else e.style.opacity="0",requestAnimationFrame(r);else r()},V=e=>{e<0||e>=l[c].triggerElements.length||void 0!==l[c].sliderElements[e]||(j(e),R(l[c].triggerElements[e],e,(()=>{U(e)})))},W=e=>{l[c].sliderElements[e].setAttribute("aria-hidden","false")},R=(e,t,r)=>{const{contentElements:n,sliderElements:s}=l[c];if(void 0!==n[t])return void(r&&"function"==typeof r&&r());const i=s[t].querySelector("div"),a=new Image,o=document.createElement("div"),d=e.querySelector("img"),u=document.createElement("div");o.className="parvus__content",u.className="parvus__loader",u.setAttribute("role","progressbar"),u.setAttribute("aria-label",p.l10n.lightboxLoadingIndicatorLabel),i.appendChild(u);new Promise(((e,t)=>{a.onload=()=>e(a),a.onerror=e=>t(e)})).then((r=>{r.style.opacity=0,o.appendChild(r),i.appendChild(o),p.captions&&((e,t,r,n)=>{const s=document.createElement("div");let i=null;if(s.className="parvus__caption","self"===p.captionsSelector)r.hasAttribute(p.captionsAttribute)&&""!==r.getAttribute(p.captionsAttribute)&&(i=r.getAttribute(p.captionsAttribute));else{const e=r.querySelector(p.captionsSelector);null!==e&&(i=e.hasAttribute(p.captionsAttribute)&&""!==e.getAttribute(p.captionsAttribute)?e.getAttribute(p.captionsAttribute):e.innerHTML)}if(null!==i){const r=`parvus__caption-${n}`;s.id=r,s.innerHTML=`

${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{m>0&&K(m-1)},Q=()=>{const{triggerElements:e}=l[c];m{void 0!==l[c].sliderElements[e]&&l[c].sliderElements[e].setAttribute("aria-hidden","true")},ee=()=>{c=null!==c?c:u,z=-m*g.offsetWidth,l[c].slider.style.transform=`translate3d(${z}px, 0, 0)`,B=z},te=()=>{const{triggerElements:e}=l[c],t=e.length;if(t<=1)return;const r=m===t-1,n=0===m?"true":null;"true"===A.getAttribute("aria-disabled")!=!!n&&(n?A.setAttribute("aria-disabled","true"):A.removeAttribute("aria-disabled"));const s=r?"true":null;"true"===w.getAttribute("aria-disabled")!=!!s&&(s?w.setAttribute("aria-disabled","true"):w.removeAttribute("aria-disabled"))},re=()=>{_.textContent=`${m+1}/${l[c].triggerElements.length}`},ne=()=>{x={startX:0,endX:0,startY:0,endY:0}},se=()=>{const e=l[c].triggerElements.length,t=l[c].slider,r=l[c].sliderElements,n=t.classList.contains("parvus__slider--is-draggable");p.simulateTouch&&p.swipeClose&&!n||p.simulateTouch&&e>1&&!n?t.classList.add("parvus__slider--is-draggable"):t.classList.remove("parvus__slider--is-draggable"),e>1?(t.setAttribute("role","region"),t.setAttribute("aria-roledescription","carousel"),t.setAttribute("aria-label",p.l10n.sliderLabel),r.forEach(((t,r)=>{t.setAttribute("role","group"),t.setAttribute("aria-label",`${p.l10n.slideLabel} ${r+1}/${e}`)}))):(t.removeAttribute("role"),t.removeAttribute("aria-roledescription"),t.removeAttribute("aria-label"),r.forEach((e=>{e.removeAttribute("role"),e.removeAttribute("aria-label")}))),1===e?(_.setAttribute("aria-hidden","true"),A.setAttribute("aria-hidden","true"),w.setAttribute("aria-hidden","true")):(_.removeAttribute("aria-hidden"),A.removeAttribute("aria-hidden"),w.removeAttribute("aria-hidden"))},ie=()=>{X||(X=!0,i.requestAnimationFrame((()=>{l[c].sliderElements.forEach(((e,t)=>{ae(e,l[c].contentElements[t])})),ee(),X=!1})))},ae=(e,t)=>{if("IMG"!==t.tagName)return;const r=t.getAttribute("height"),n=t.getAttribute("width");if(!r||!n)return;const s=getComputedStyle(e),i=parseFloat(s.paddingLeft)+parseFloat(s.paddingRight),a=parseFloat(s.paddingTop)+parseFloat(s.paddingBottom),l=e.querySelector(".parvus__caption"),o=l?l.getBoundingClientRect().height:0,d=e.offsetWidth-i,u=e.offsetHeight-a-o,c=Math.min(d/n||0,u/r||0),m=n*c,p=r*c,g=n<=d&&r<=u;t.style.width=g?"":`${m}px`,t.style.height=g?"":`${p}px`},le=e=>{e.style.transition="transform 0.3s ease",e.style.transform="",setTimeout((()=>{e.style.transition="",e.style.transformOrigin=""}),300),k=!1,T=!1,N=1,$=0,S="",g.classList.remove("parvus--is-zooming")},oe=function(e){e.preventDefault(),P(this)},de=e=>{const{target:t}=e;t===A?J():t===w?Q():(t===L||p.docClose&&!I&&!C&&t.classList.contains("parvus__slide"))&&G(),e.stopPropagation()},ue=t=>{const r=(n=g,Array.from(n.querySelectorAll(e.join(", "))).filter((e=>null!==e.offsetParent)));var n;const s=r.indexOf(document.activeElement),i=r.length-1;switch(t.code){case"Tab":t.shiftKey?0===s&&(r[i].focus(),t.preventDefault()):s===i&&(r[0].focus(),t.preventDefault());break;case"Escape":G(),t.preventDefault();break;case"ArrowLeft":J(),t.preventDefault();break;case"ArrowRight":Q(),t.preventDefault()}},ce=e=>{e.preventDefault(),e.stopPropagation(),C=!1,I=!1,M=!0,o.set(e.pointerId,e),x.startX=e.pageX,x.startY=e.pageY,x.endX=e.pageX,x.endY=e.pageY;const{slider:t}=l[c];t.classList.add("parvus__slider--is-dragging"),t.style.willChange="transform",T=1===o.size,p.swipeClose&&(v=getComputedStyle(h).opacity)},me=e=>{if(e.preventDefault(),!M)return;const t=l[c].contentElements[m];if(o.set(e.pointerId,e),t&&"IMG"===t.tagName){if(2===o.size)return void(e=>{const t=Array.from(o.values()),r=Math.hypot(t[1].clientX-t[0].clientX,t[1].clientY-t[0].clientY),n=(t[0].clientX+t[1].clientX)/2,s=(t[0].clientY+t[1].clientY)/2,i=e.getBoundingClientRect(),a=(n-i.left)/i.width,l=(s-i.top)/i.height,d=t.map((e=>e.pointerId)).sort().join("-"),u=S!==d;k&&!u||(k=!0,S=d,$=r/N,(!e.style.transformOrigin&&1===N||1===N&&u)&&(e.style.transformOrigin=`${100*a}% ${100*l}%`),g.classList.add("parvus--is-zooming"));const c=r/$;N=Math.min(Math.max(1,c),3),e.style.willChange="transform",e.style.transform=`scale(${N})`})(t);if(N>1)return}x.endX=e.pageX,x.endY=e.pageY,ge()},pe=e=>{e.stopPropagation();const{slider:t}=l[c];if(o.delete(e.pointerId),o.size>0)return;M=!1;const r=l[c].contentElements[m],n=Math.abs(x.endX-x.startX),s=Math.abs(x.endY-x.startY),i=n<8&&s<8&&!C&&!I&&T;t.classList.remove("parvus__slider--is-dragging"),t.style.willChange="",N>1?i?le(r):r.style.transform=`\n scale(${N})\n `:(k&&le(r),(x.endX||x.endY)&&(()=>{const{startX:e,startY:t,endX:r,endY:n}=x,s=r-e,i=n-t,a=Math.abs(s),o=Math.abs(i),{triggerElements:d}=l[c],u=d.length;if(C){const e=s>0;a>=p.threshold&&(e&&m>0?J():!e&&m=p.threshold&&p.swipeClose?G():(g.classList.remove("parvus--is-vertical-closing"),ee()),h.style.opacity=""):ee()})()),ne()},ge=()=>{const{startX:e,endX:t,startY:r,endY:n}=x,s=e-t,i=n-r,a=Math.abs(s),o=Math.abs(i),d=l[c],u=d.slider,b=d.triggerElements.length,f=(e,t)=>{if(0===m&&e<0||m===b-1&&e>0){const r=e*(1/(1+Math.pow(t/100,.15)));u.style.transform=`\n translate3d(${B-Math.round(r)}px, 0, 0)\n `}else u.style.transform=`\n translate3d(${B-Math.round(e)}px, 0, 0)\n `},E=(e,t)=>{if(!Y&&t<=100){const e=Math.max(0,v-t/100);h.style.opacity=e}g.classList.add("parvus--is-vertical-closing"),u.style.transform=`\n translate3d(${B}px, ${Math.round(e)}px, 0)\n `};C||I?C?f(s,a):I&&E(i,o):(a>1.5||o>1.5)&&(a>1.15*o&&b>1?(C=!0,I=!1,f(s,a)):o>1.15*a&&p.swipeClose&&(C=!1,I=!0,E(i,o)))},he=()=>{i.addEventListener("keydown",ue),i.addEventListener("resize",ie),i.addEventListener("popstate",G),q.addEventListener("change",O),g.addEventListener("click",de),g.addEventListener("pointerdown",ce,{passive:!1}),g.addEventListener("pointerup",pe,{passive:!0}),g.addEventListener("pointermove",me,{passive:!1})},ve=()=>{i.removeEventListener("keydown",ue),i.removeEventListener("resize",ie),i.removeEventListener("popstate",G),q.removeEventListener("change",O),g.removeEventListener("click",de),g.removeEventListener("pointerdown",ce),g.removeEventListener("pointerup",pe),g.removeEventListener("pointermove",me)},be=()=>g.hasAttribute("open"),fe=e=>{const t=new CustomEvent(e,{cancelable:!0});g.dispatchEvent(t)},Ee=()=>{if(p=(e=>{const t={selector:".lightbox",gallerySelector:null,zoomIndicator:!0,captions:!0,captionsSelector:"self",captionsAttribute:"data-caption",docClose:!0,swipeClose:!0,simulateTouch:!0,threshold:50,hideScrollbar:!0,lightboxIndicatorIcon:'',previousButtonIcon:'',nextButtonIcon:'',closeButtonIcon:'',l10n:s},r={...t,...e};return e&&e.l10n&&(r.l10n={...t.l10n,...e.l10n}),r})(t),O(),null!==p.gallerySelector){document.querySelectorAll(p.gallerySelector).forEach(((e,t)=>{const r=t;e.querySelectorAll(p.selector).forEach((e=>{e.setAttribute("data-group",`parvus-gallery-${r}`),D(e)}))}))}document.querySelectorAll(`${p.selector}:not(.parvus-trigger)`).forEach(D)};return Ee(),{init:Ee,open:P,close:G,select:K,previous:J,next:Q,currentIndex:()=>m,add:D,remove:e=>{if(!e||!e.hasAttribute("data-group"))return;const t=H(e),r=l[t];if(!r)return;const s=r.triggerElements.indexOf(e);if(-1===s)return;const i=be()&&t===c&&s===m;if(r.contentElements[s]){const e=r.contentElements[s];"IMG"===e.tagName&&(e.src="",e.srcset="")}const a=r.sliderElements[s];a&&a.parentNode&&a.parentNode.removeChild(a),r.triggerElements.splice(s,1),r.sliderElements.splice(s,1),r.contentElements.splice(s,1),p.zoomIndicator&&n(e),be()&&t===c&&(i?0===r.triggerElements.length?G():m>=r.triggerElements.length?K(r.triggerElements.length-1):(se(),te(),re()):s{g&&(be()&&G(),setTimeout((()=>{ve();["open","close","select","destroy"].forEach((e=>{(g._listeners?.[e]||[]).forEach((t=>{g.removeEventListener(e,t)}))}));document.querySelectorAll(".parvus-trigger").forEach((e=>{e.removeEventListener("click",oe),e.classList.remove("parvus-trigger"),p.zoomIndicator&&n(e),e.dataset.group&&delete e.dataset.group})),fe("destroy"),g.remove(),g=null,h=null,b=null,f=null,E=null,y=null,A=null,w=null,L=null,_=null,Object.keys(l).forEach((e=>{const t=l[e];t&&t.contentElements&&t.contentElements.forEach((e=>{e&&"IMG"===e.tagName&&(e.src="",e.srcset="")})),delete l[e]})),d=0,u=null,c=null,m=0}),1e3))},isOpen:be,on:(e,t)=>{g&&g.addEventListener(e,t)},off:(e,t)=>{g&&g.removeEventListener(e,t)}}}export{i as default}; 12 | -------------------------------------------------------------------------------- /dist/js/parvus.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 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).Parvus=t()}(this,(function(){"use strict";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"};return function(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||j(),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);F(t),R(e,t,(()=>{U(t)})),se(),te(),re()}},j=()=>{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)},F=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{const t=l[c].sliderElements;for(let r=e-1;r>=0;r--)if(void 0!==t[r])return r;return-1})(e);-1!==t?s.sliderElements[t].after(r):s.slider.prepend(r)}},P=e=>{if(!g||!e||!e.classList.contains("parvus-trigger")||be())return;c=H(e);const t=l[c],n=t.triggerElements.indexOf(e);if(-1===n)throw new Error("Ups, element not found in group.");m=n,history.pushState({parvus:"close"},"Image",window.location.href),he(),p.hideScrollbar&&(document.body.style.marginInlineEnd=`${r()}px`,document.body.style.overflow="hidden"),g.classList.add("parvus--is-opening"),g.showModal(),(()=>{const e=document.createElement("div");e.className="parvus__slider",l[c].slider=e,g.appendChild(e)})(),F(m),ee(),se(),te(),re(),W(m),R(e,m,(()=>{U(m,!0),g.classList.remove("parvus--is-opening"),t.slider.classList.add("parvus__slider--animate")})),V(m+1),V(m-1),fe("open")},G=()=>{if(!be())return;const e=l[c].contentElements[m],t=l[c].triggerElements[m];ve(),ne(),"close"===history.state?.parvus&&history.back(),g.classList.add("parvus--is-closing");const r=()=>{k&&le(e),Z(m),g.close(),g.classList.remove("parvus--is-closing"),g.classList.remove("parvus--is-vertical-closing"),l[c].slider.remove(),l[c].slider=null,l[c].sliderElements=[],l[c].contentElements=[],_.removeAttribute("aria-hidden"),A.removeAttribute("aria-hidden"),A.removeAttribute("aria-disabled"),w.removeAttribute("aria-hidden"),w.removeAttribute("aria-disabled"),p.hideScrollbar&&(document.body.style.marginInlineEnd="",document.body.style.overflow="")};if(e&&"IMG"===e.tagName)if(document.startViewTransition){e.style.viewTransitionName="lightboximage";document.startViewTransition((()=>{e.style.opacity="0",e.style.viewTransitionName=null,t.style.viewTransitionName="lightboximage"})).finished.finally((()=>{r(),t.style.viewTransitionName=null}))}else e.style.opacity="0",requestAnimationFrame(r);else r()},V=e=>{e<0||e>=l[c].triggerElements.length||void 0!==l[c].sliderElements[e]||(F(e),R(l[c].triggerElements[e],e,(()=>{U(e)})))},W=e=>{l[c].sliderElements[e].setAttribute("aria-hidden","false")},R=(e,t,r)=>{const{contentElements:n,sliderElements:s}=l[c];if(void 0!==n[t])return void(r&&"function"==typeof r&&r());const i=s[t].querySelector("div"),a=new Image,o=document.createElement("div"),d=e.querySelector("img"),u=document.createElement("div");o.className="parvus__content",u.className="parvus__loader",u.setAttribute("role","progressbar"),u.setAttribute("aria-label",p.l10n.lightboxLoadingIndicatorLabel),i.appendChild(u);new Promise(((e,t)=>{a.onload=()=>e(a),a.onerror=e=>t(e)})).then((r=>{r.style.opacity=0,o.appendChild(r),i.appendChild(o),p.captions&&((e,t,r,n)=>{const s=document.createElement("div");let i=null;if(s.className="parvus__caption","self"===p.captionsSelector)r.hasAttribute(p.captionsAttribute)&&""!==r.getAttribute(p.captionsAttribute)&&(i=r.getAttribute(p.captionsAttribute));else{const e=r.querySelector(p.captionsSelector);null!==e&&(i=e.hasAttribute(p.captionsAttribute)&&""!==e.getAttribute(p.captionsAttribute)?e.getAttribute(p.captionsAttribute):e.innerHTML)}if(null!==i){const r=`parvus__caption-${n}`;s.id=r,s.innerHTML=`

${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{m>0&&K(m-1)},Q=()=>{const{triggerElements:e}=l[c];m{void 0!==l[c].sliderElements[e]&&l[c].sliderElements[e].setAttribute("aria-hidden","true")},ee=()=>{c=null!==c?c:u,z=-m*g.offsetWidth,l[c].slider.style.transform=`translate3d(${z}px, 0, 0)`,B=z},te=()=>{const{triggerElements:e}=l[c],t=e.length;if(t<=1)return;const r=m===t-1,n=0===m?"true":null;"true"===A.getAttribute("aria-disabled")!=!!n&&(n?A.setAttribute("aria-disabled","true"):A.removeAttribute("aria-disabled"));const s=r?"true":null;"true"===w.getAttribute("aria-disabled")!=!!s&&(s?w.setAttribute("aria-disabled","true"):w.removeAttribute("aria-disabled"))},re=()=>{_.textContent=`${m+1}/${l[c].triggerElements.length}`},ne=()=>{x={startX:0,endX:0,startY:0,endY:0}},se=()=>{const e=l[c].triggerElements.length,t=l[c].slider,r=l[c].sliderElements,n=t.classList.contains("parvus__slider--is-draggable");p.simulateTouch&&p.swipeClose&&!n||p.simulateTouch&&e>1&&!n?t.classList.add("parvus__slider--is-draggable"):t.classList.remove("parvus__slider--is-draggable"),e>1?(t.setAttribute("role","region"),t.setAttribute("aria-roledescription","carousel"),t.setAttribute("aria-label",p.l10n.sliderLabel),r.forEach(((t,r)=>{t.setAttribute("role","group"),t.setAttribute("aria-label",`${p.l10n.slideLabel} ${r+1}/${e}`)}))):(t.removeAttribute("role"),t.removeAttribute("aria-roledescription"),t.removeAttribute("aria-label"),r.forEach((e=>{e.removeAttribute("role"),e.removeAttribute("aria-label")}))),1===e?(_.setAttribute("aria-hidden","true"),A.setAttribute("aria-hidden","true"),w.setAttribute("aria-hidden","true")):(_.removeAttribute("aria-hidden"),A.removeAttribute("aria-hidden"),w.removeAttribute("aria-hidden"))},ie=()=>{X||(X=!0,i.requestAnimationFrame((()=>{l[c].sliderElements.forEach(((e,t)=>{ae(e,l[c].contentElements[t])})),ee(),X=!1})))},ae=(e,t)=>{if("IMG"!==t.tagName)return;const r=t.getAttribute("height"),n=t.getAttribute("width");if(!r||!n)return;const s=getComputedStyle(e),i=parseFloat(s.paddingLeft)+parseFloat(s.paddingRight),a=parseFloat(s.paddingTop)+parseFloat(s.paddingBottom),l=e.querySelector(".parvus__caption"),o=l?l.getBoundingClientRect().height:0,d=e.offsetWidth-i,u=e.offsetHeight-a-o,c=Math.min(d/n||0,u/r||0),m=n*c,p=r*c,g=n<=d&&r<=u;t.style.width=g?"":`${m}px`,t.style.height=g?"":`${p}px`},le=e=>{e.style.transition="transform 0.3s ease",e.style.transform="",setTimeout((()=>{e.style.transition="",e.style.transformOrigin=""}),300),k=!1,T=!1,N=1,$=0,S="",g.classList.remove("parvus--is-zooming")},oe=function(e){e.preventDefault(),P(this)},de=e=>{const{target:t}=e;t===A?J():t===w?Q():(t===L||p.docClose&&!I&&!C&&t.classList.contains("parvus__slide"))&&G(),e.stopPropagation()},ue=t=>{const r=(n=g,Array.from(n.querySelectorAll(e.join(", "))).filter((e=>null!==e.offsetParent)));var n;const s=r.indexOf(document.activeElement),i=r.length-1;switch(t.code){case"Tab":t.shiftKey?0===s&&(r[i].focus(),t.preventDefault()):s===i&&(r[0].focus(),t.preventDefault());break;case"Escape":G(),t.preventDefault();break;case"ArrowLeft":J(),t.preventDefault();break;case"ArrowRight":Q(),t.preventDefault()}},ce=e=>{e.preventDefault(),e.stopPropagation(),C=!1,I=!1,M=!0,o.set(e.pointerId,e),x.startX=e.pageX,x.startY=e.pageY,x.endX=e.pageX,x.endY=e.pageY;const{slider:t}=l[c];t.classList.add("parvus__slider--is-dragging"),t.style.willChange="transform",T=1===o.size,p.swipeClose&&(v=getComputedStyle(h).opacity)},me=e=>{if(e.preventDefault(),!M)return;const t=l[c].contentElements[m];if(o.set(e.pointerId,e),t&&"IMG"===t.tagName){if(2===o.size)return void(e=>{const t=Array.from(o.values()),r=Math.hypot(t[1].clientX-t[0].clientX,t[1].clientY-t[0].clientY),n=(t[0].clientX+t[1].clientX)/2,s=(t[0].clientY+t[1].clientY)/2,i=e.getBoundingClientRect(),a=(n-i.left)/i.width,l=(s-i.top)/i.height,d=t.map((e=>e.pointerId)).sort().join("-"),u=S!==d;k&&!u||(k=!0,S=d,$=r/N,(!e.style.transformOrigin&&1===N||1===N&&u)&&(e.style.transformOrigin=`${100*a}% ${100*l}%`),g.classList.add("parvus--is-zooming"));const c=r/$;N=Math.min(Math.max(1,c),3),e.style.willChange="transform",e.style.transform=`scale(${N})`})(t);if(N>1)return}x.endX=e.pageX,x.endY=e.pageY,ge()},pe=e=>{e.stopPropagation();const{slider:t}=l[c];if(o.delete(e.pointerId),o.size>0)return;M=!1;const r=l[c].contentElements[m],n=Math.abs(x.endX-x.startX),s=Math.abs(x.endY-x.startY),i=n<8&&s<8&&!C&&!I&&T;t.classList.remove("parvus__slider--is-dragging"),t.style.willChange="",N>1?i?le(r):r.style.transform=`\n scale(${N})\n `:(k&&le(r),(x.endX||x.endY)&&(()=>{const{startX:e,startY:t,endX:r,endY:n}=x,s=r-e,i=n-t,a=Math.abs(s),o=Math.abs(i),{triggerElements:d}=l[c],u=d.length;if(C){const e=s>0;a>=p.threshold&&(e&&m>0?J():!e&&m=p.threshold&&p.swipeClose?G():(g.classList.remove("parvus--is-vertical-closing"),ee()),h.style.opacity=""):ee()})()),ne()},ge=()=>{const{startX:e,endX:t,startY:r,endY:n}=x,s=e-t,i=n-r,a=Math.abs(s),o=Math.abs(i),d=l[c],u=d.slider,b=d.triggerElements.length,f=(e,t)=>{if(0===m&&e<0||m===b-1&&e>0){const r=e*(1/(1+Math.pow(t/100,.15)));u.style.transform=`\n translate3d(${B-Math.round(r)}px, 0, 0)\n `}else u.style.transform=`\n translate3d(${B-Math.round(e)}px, 0, 0)\n `},E=(e,t)=>{if(!Y&&t<=100){const e=Math.max(0,v-t/100);h.style.opacity=e}g.classList.add("parvus--is-vertical-closing"),u.style.transform=`\n translate3d(${B}px, ${Math.round(e)}px, 0)\n `};C||I?C?f(s,a):I&&E(i,o):(a>1.5||o>1.5)&&(a>1.15*o&&b>1?(C=!0,I=!1,f(s,a)):o>1.15*a&&p.swipeClose&&(C=!1,I=!0,E(i,o)))},he=()=>{i.addEventListener("keydown",ue),i.addEventListener("resize",ie),i.addEventListener("popstate",G),q.addEventListener("change",O),g.addEventListener("click",de),g.addEventListener("pointerdown",ce,{passive:!1}),g.addEventListener("pointerup",pe,{passive:!0}),g.addEventListener("pointermove",me,{passive:!1})},ve=()=>{i.removeEventListener("keydown",ue),i.removeEventListener("resize",ie),i.removeEventListener("popstate",G),q.removeEventListener("change",O),g.removeEventListener("click",de),g.removeEventListener("pointerdown",ce),g.removeEventListener("pointerup",pe),g.removeEventListener("pointermove",me)},be=()=>g.hasAttribute("open"),fe=e=>{const t=new CustomEvent(e,{cancelable:!0});g.dispatchEvent(t)},Ee=()=>{if(p=(e=>{const t={selector:".lightbox",gallerySelector:null,zoomIndicator:!0,captions:!0,captionsSelector:"self",captionsAttribute:"data-caption",docClose:!0,swipeClose:!0,simulateTouch:!0,threshold:50,hideScrollbar:!0,lightboxIndicatorIcon:'',previousButtonIcon:'',nextButtonIcon:'',closeButtonIcon:'',l10n:s},r={...t,...e};return e&&e.l10n&&(r.l10n={...t.l10n,...e.l10n}),r})(t),O(),null!==p.gallerySelector){document.querySelectorAll(p.gallerySelector).forEach(((e,t)=>{const r=t;e.querySelectorAll(p.selector).forEach((e=>{e.setAttribute("data-group",`parvus-gallery-${r}`),D(e)}))}))}document.querySelectorAll(`${p.selector}:not(.parvus-trigger)`).forEach(D)};return Ee(),{init:Ee,open:P,close:G,select:K,previous:J,next:Q,currentIndex:()=>m,add:D,remove:e=>{if(!e||!e.hasAttribute("data-group"))return;const t=H(e),r=l[t];if(!r)return;const s=r.triggerElements.indexOf(e);if(-1===s)return;const i=be()&&t===c&&s===m;if(r.contentElements[s]){const e=r.contentElements[s];"IMG"===e.tagName&&(e.src="",e.srcset="")}const a=r.sliderElements[s];a&&a.parentNode&&a.parentNode.removeChild(a),r.triggerElements.splice(s,1),r.sliderElements.splice(s,1),r.contentElements.splice(s,1),p.zoomIndicator&&n(e),be()&&t===c&&(i?0===r.triggerElements.length?G():m>=r.triggerElements.length?K(r.triggerElements.length-1):(se(),te(),re()):s{g&&(be()&&G(),setTimeout((()=>{ve();["open","close","select","destroy"].forEach((e=>{(g._listeners?.[e]||[]).forEach((t=>{g.removeEventListener(e,t)}))}));document.querySelectorAll(".parvus-trigger").forEach((e=>{e.removeEventListener("click",oe),e.classList.remove("parvus-trigger"),p.zoomIndicator&&n(e),e.dataset.group&&delete e.dataset.group})),fe("destroy"),g.remove(),g=null,h=null,b=null,f=null,E=null,y=null,A=null,w=null,L=null,_=null,Object.keys(l).forEach((e=>{const t=l[e];t&&t.contentElements&&t.contentElements.forEach((e=>{e&&"IMG"===e.tagName&&(e.src="",e.srcset="")})),delete l[e]})),d=0,u=null,c=null,m=0}),1e3))},isOpen:be,on:(e,t)=>{g&&g.addEventListener(e,t)},off:(e,t)=>{g&&g.removeEventListener(e,t)}}}})); 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parvus", 3 | "type": "module", 4 | "version": "3.0.0", 5 | "description": "An open source, dependency free image lightbox with the goal of being accessible.", 6 | "main": "./dist/js/parvus.js", 7 | "module": "./dist/js/parvus.esm.js", 8 | "style": "./dist/css/parvus.css", 9 | "devDependencies": { 10 | "@babel/core": "^7.26.10", 11 | "@babel/preset-env": "^7.26.9", 12 | "@rollup/plugin-babel": "^6.0.4", 13 | "@rollup/plugin-commonjs": "^28.0.3", 14 | "@rollup/plugin-node-resolve": "^16.0.1", 15 | "@rollup/plugin-terser": "^0.4.4", 16 | "core-js": "^3.41.0", 17 | "postcss": "^8.5.3", 18 | "rollup": "^4.35.0", 19 | "rollup-plugin-license": "^3.6.0", 20 | "rollup-plugin-postcss": "^4.0.2", 21 | "sass": "^1.85.1", 22 | "standard": "^17.1.2", 23 | "stylelint": "^16.16.0", 24 | "stylelint-config-standard-scss": "^14.0.0", 25 | "stylelint-scss": "^6.11.1", 26 | "stylelint-use-logical": "^2.1.2" 27 | }, 28 | "browserslist": [ 29 | "last 2 versions", 30 | "> 1%", 31 | "not dead" 32 | ], 33 | "standard": { 34 | "globals": [ 35 | "Image", 36 | "history", 37 | "CustomEvent", 38 | "requestAnimationFrame", 39 | "getComputedStyle" 40 | ] 41 | }, 42 | "scripts": { 43 | "build": "npm run testCss && npm run buildCss && npm run testJs && npm run buildJs", 44 | "buildCss": "rollup -c --environment BUILDCSS --bundleConfigAsCjs", 45 | "buildJs": "rollup -c --environment BUILDJS --bundleConfigAsCjs", 46 | "buildWatch": "npm run buildWatchJs && npm run buildWatchCss", 47 | "buildWatchCss": "rollup -c -w --environment BUILDCSS --bundleConfigAsCjs", 48 | "buildWatchJs": "rollup -c -w --environment BUILDJS --bundleConfigAsCjs", 49 | "testCss": "stylelint \"src/scss/parvus.scss\"", 50 | "testJs": "standard \"src/js/parvus.js\"", 51 | "test": "npm run testCss && npm run testJs" 52 | }, 53 | "exports": { 54 | ".": { 55 | "import": "./dist/js/parvus.esm.js", 56 | "require": "./dist/js/parvus.js" 57 | }, 58 | "./src/scss/*": "./src/scss/*.scss", 59 | "./src/l10n/*": "./src/l10n/*.js" 60 | }, 61 | "repository": { 62 | "type": "git", 63 | "url": "git://github.com/deoostfrees/parvus.git" 64 | }, 65 | "keywords": [ 66 | "lightbox", 67 | "accessible", 68 | "a11y", 69 | "javascript", 70 | "vanilla", 71 | "scss", 72 | "css" 73 | ], 74 | "author": "Benjamin de Oostfrees", 75 | "license": "MIT", 76 | "bugs": { 77 | "url": "https://github.com/deoostfrees/parvus/issues" 78 | }, 79 | "homepage": "https://github.com/deoostfrees/parvus" 80 | } 81 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | import terser from '@rollup/plugin-terser' 4 | import postcss from 'rollup-plugin-postcss' 5 | import babel from '@rollup/plugin-babel' 6 | import license from 'rollup-plugin-license' 7 | 8 | import pkg from './package.json' 9 | 10 | const bannerContent = ` 11 | Parvus 12 | 13 | @author ${pkg.author} 14 | @version ${pkg.version} 15 | @url ${pkg.homepage} 16 | 17 | ${pkg.license} license` 18 | 19 | const rollupBuilds = [] 20 | 21 | /** 22 | * Build JavaScript 23 | * 24 | */ 25 | if (process.env.BUILDJS) { 26 | rollupBuilds.push({ 27 | input: './src/js/parvus.js', 28 | output: [ 29 | { 30 | format: 'umd', 31 | file: './dist/js/parvus.js', 32 | name: 'Parvus' 33 | }, 34 | { 35 | format: 'es', 36 | file: './dist/js/parvus.esm.js', 37 | name: 'Parvus' 38 | }, 39 | { 40 | format: 'umd', 41 | file: './dist/js/parvus.min.js', 42 | name: 'Parvus', 43 | plugins: [ 44 | terser(), 45 | license({ 46 | banner: { 47 | content: bannerContent 48 | } 49 | }) 50 | ] 51 | }, 52 | { 53 | format: 'es', 54 | file: './dist/js/parvus.esm.min.js', 55 | name: 'Parvus', 56 | plugins: [ 57 | terser(), 58 | license({ 59 | banner: { 60 | content: bannerContent 61 | } 62 | }) 63 | ] 64 | } 65 | ], 66 | plugins: [ 67 | resolve({ 68 | browser: true 69 | }), 70 | commonjs(), 71 | babel({ 72 | babelHelpers: 'bundled', 73 | exclude: 'node_modules/**', 74 | presets: [ 75 | ['@babel/preset-env', { 76 | corejs: 3.15, 77 | useBuiltIns: 'entry' 78 | }] 79 | ] 80 | }), 81 | license({ 82 | banner: { 83 | content: bannerContent 84 | } 85 | }) 86 | ], 87 | watch: { 88 | clearScreen: false 89 | } 90 | }) 91 | } 92 | 93 | /** 94 | * Build CSS 95 | * 96 | */ 97 | if (process.env.BUILDCSS) { 98 | rollupBuilds.push( 99 | { 100 | input: './src/scss/parvus.scss', 101 | output: [ 102 | { 103 | file: './dist/css/parvus.css' 104 | } 105 | ], 106 | plugins: [ 107 | resolve({ 108 | browser: true 109 | }), 110 | commonjs(), 111 | postcss({ 112 | extract: true 113 | }), 114 | license({ 115 | banner: { 116 | content: bannerContent 117 | } 118 | }) 119 | ], 120 | watch: { 121 | clearScreen: false 122 | } 123 | }, 124 | { 125 | input: './src/scss/parvus.scss', 126 | output: [ 127 | { 128 | file: './dist/css/parvus.min.css' 129 | } 130 | ], 131 | plugins: [ 132 | resolve({ 133 | browser: true 134 | }), 135 | commonjs(), 136 | postcss({ 137 | extract: true, 138 | minimize: true 139 | }), 140 | license({ 141 | banner: { 142 | content: bannerContent 143 | } 144 | }) 145 | ], 146 | watch: { 147 | clearScreen: false 148 | } 149 | } 150 | ) 151 | } 152 | 153 | export default rollupBuilds 154 | -------------------------------------------------------------------------------- /src/js/get-focusable-children.js: -------------------------------------------------------------------------------- 1 | const FOCUSABLE_ELEMENTS = [ 2 | 'a:not([inert]):not([tabindex^="-"])', 3 | 'button:not([inert]):not([tabindex^="-"]):not(:disabled)', 4 | '[tabindex]:not([inert]):not([tabindex^="-"])' 5 | ] 6 | 7 | /** 8 | * Get the focusable children of the given element 9 | * 10 | * @return {Array} - An array of focusable children 11 | */ 12 | export const getFocusableChildren = (targetEl) => { 13 | return Array.from(targetEl.querySelectorAll(FOCUSABLE_ELEMENTS.join(', '))) 14 | .filter((child) => child.offsetParent !== null) 15 | } 16 | -------------------------------------------------------------------------------- /src/js/get-scrollbar-width.js: -------------------------------------------------------------------------------- 1 | const BROWSER_WINDOW = window 2 | 3 | /** 4 | * Get scrollbar width 5 | * 6 | * @return {Number} - The scrollbar width 7 | */ 8 | export const getScrollbarWidth = () => { 9 | return BROWSER_WINDOW.innerWidth - document.documentElement.clientWidth 10 | } 11 | -------------------------------------------------------------------------------- /src/js/parvus.js: -------------------------------------------------------------------------------- 1 | import { getFocusableChildren } from './get-focusable-children' 2 | import { getScrollbarWidth } from './get-scrollbar-width' 3 | import { addZoomIndicator, removeZoomIndicator } from './zoom-indicator' 4 | 5 | // Default language 6 | import en from '../l10n/en.js' 7 | 8 | /** 9 | * Parvus Lightbox 10 | * 11 | * @param {Object} userOptions - User configuration options 12 | * @returns {Object} Parvus instance 13 | */ 14 | export default function Parvus (userOptions) { 15 | const BROWSER_WINDOW = window 16 | const GROUP_ATTRIBUTES = { 17 | triggerElements: [], 18 | slider: null, 19 | sliderElements: [], 20 | contentElements: [] 21 | } 22 | const GROUPS = {} 23 | const activePointers = new Map() 24 | let groupIdCounter = 0 25 | let newGroup = null 26 | let activeGroup = null 27 | let currentIndex = 0 28 | let config = {} 29 | let lightbox = null 30 | let lightboxOverlay = null 31 | let lightboxOverlayOpacity = 1 32 | let toolbar = null 33 | let toolbarLeft = null 34 | let toolbarRight = null 35 | let controls = null 36 | let previousButton = null 37 | let nextButton = null 38 | let closeButton = null 39 | let counter = null 40 | let drag = {} 41 | let isDraggingX = false 42 | let isDraggingY = false 43 | let pointerDown = false 44 | let currentScale = 1 45 | let isPinching = false 46 | let isTap = false 47 | let pinchStartDistance = 0 48 | let lastPointersId = null 49 | let offset = null 50 | let offsetTmp = null 51 | let resizeTicking = false 52 | let isReducedMotion = true 53 | 54 | /** 55 | * Merge default options with user-provided options 56 | * 57 | * @param {Object} userOptions - User-provided options 58 | * @returns {Object} - Merged options object 59 | */ 60 | const mergeOptions = (userOptions) => { 61 | // Default options 62 | const DEFAULT_OPTIONS = { 63 | selector: '.lightbox', 64 | gallerySelector: null, 65 | zoomIndicator: true, 66 | captions: true, 67 | captionsSelector: 'self', 68 | captionsAttribute: 'data-caption', 69 | docClose: true, 70 | swipeClose: true, 71 | simulateTouch: true, 72 | threshold: 50, 73 | hideScrollbar: true, 74 | lightboxIndicatorIcon: '', 75 | previousButtonIcon: '', 76 | nextButtonIcon: '', 77 | closeButtonIcon: '', 78 | l10n: en 79 | } 80 | 81 | const MERGED_OPTIONS = { 82 | ...DEFAULT_OPTIONS, 83 | ...userOptions 84 | } 85 | 86 | if (userOptions && userOptions.l10n) { 87 | MERGED_OPTIONS.l10n = { 88 | ...DEFAULT_OPTIONS.l10n, 89 | ...userOptions.l10n 90 | } 91 | } 92 | 93 | return MERGED_OPTIONS 94 | } 95 | 96 | /** 97 | * Check prefers reduced motion 98 | * https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList 99 | * 100 | */ 101 | const MOTIONQUERY = BROWSER_WINDOW.matchMedia('(prefers-reduced-motion)') 102 | 103 | const reducedMotionCheck = () => { 104 | if (MOTIONQUERY.matches) { 105 | isReducedMotion = true 106 | } else { 107 | isReducedMotion = false 108 | } 109 | } 110 | 111 | /** 112 | * Retrieves or creates a group identifier for the given element 113 | * 114 | * @param {HTMLElement} el - DOM element to get or assign a group to 115 | * @returns {string} The group identifier associated with the element 116 | */ 117 | const getGroup = (el) => { 118 | // Return existing group identifier if already assigned 119 | if (el.dataset.group) { 120 | return el.dataset.group 121 | } 122 | 123 | // Generate new unique group identifier using counter 124 | const EL_GROUP = `default-${groupIdCounter++}` 125 | 126 | // Assign the new group identifier to element's dataset 127 | el.dataset.group = EL_GROUP 128 | 129 | return EL_GROUP 130 | } 131 | 132 | /** 133 | * Add an element 134 | * 135 | * @param {HTMLElement} el - The element to be added 136 | */ 137 | const add = (el) => { 138 | // Check element type and attributes 139 | const IS_VALID_LINK = el.tagName === 'A' && el.hasAttribute('href') 140 | const IS_VALID_BUTTON = el.tagName === 'BUTTON' && el.hasAttribute('data-target') 141 | 142 | if (!IS_VALID_LINK && !IS_VALID_BUTTON) { 143 | 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.') 144 | } 145 | 146 | // Check if the lightbox already exists 147 | if (!lightbox) { 148 | createLightbox() 149 | } 150 | 151 | newGroup = getGroup(el) 152 | 153 | if (!GROUPS[newGroup]) { 154 | GROUPS[newGroup] = structuredClone(GROUP_ATTRIBUTES) 155 | } 156 | 157 | if (GROUPS[newGroup].triggerElements.includes(el)) { 158 | throw new Error('Ups, element already added.') 159 | } 160 | 161 | GROUPS[newGroup].triggerElements.push(el) 162 | 163 | if (config.zoomIndicator) { 164 | addZoomIndicator(el, config) 165 | } 166 | 167 | el.classList.add('parvus-trigger') 168 | el.addEventListener('click', triggerParvus) 169 | 170 | if (isOpen() && newGroup === activeGroup) { 171 | const EL_INDEX = GROUPS[newGroup].triggerElements.indexOf(el) 172 | 173 | createSlide(EL_INDEX) 174 | createImage(el, EL_INDEX, () => { 175 | loadImage(EL_INDEX) 176 | }) 177 | updateAttributes() 178 | updateSliderNavigationStatus() 179 | updateCounter() 180 | } 181 | } 182 | 183 | /** 184 | * Remove an element 185 | * 186 | * @param {HTMLElement} el - The element to be removed 187 | */ 188 | const remove = (el) => { 189 | if (!el || !el.hasAttribute('data-group')) { 190 | return 191 | } 192 | 193 | const EL_GROUP = getGroup(el) 194 | const GROUP = GROUPS[EL_GROUP] 195 | 196 | // Check if element exists 197 | if (!GROUP) { 198 | return 199 | } 200 | 201 | const EL_INDEX = GROUP.triggerElements.indexOf(el) 202 | 203 | if (EL_INDEX === -1) { 204 | return 205 | } 206 | 207 | const IS_CURRENT_EL = isOpen() && EL_GROUP === activeGroup && EL_INDEX === currentIndex 208 | 209 | // Remove group data 210 | if (GROUP.contentElements[EL_INDEX]) { 211 | const content = GROUP.contentElements[EL_INDEX] 212 | 213 | if (content.tagName === 'IMG') { 214 | content.src = '' 215 | content.srcset = '' 216 | } 217 | } 218 | 219 | // Remove DOM element 220 | const sliderElement = GROUP.sliderElements[EL_INDEX] 221 | 222 | if (sliderElement && sliderElement.parentNode) { 223 | sliderElement.parentNode.removeChild(sliderElement) 224 | } 225 | 226 | // Remove all array elements 227 | GROUP.triggerElements.splice(EL_INDEX, 1) 228 | GROUP.sliderElements.splice(EL_INDEX, 1) 229 | GROUP.contentElements.splice(EL_INDEX, 1) 230 | 231 | if (config.zoomIndicator) { 232 | removeZoomIndicator(el) 233 | } 234 | 235 | if (isOpen() && EL_GROUP === activeGroup) { 236 | if (IS_CURRENT_EL) { 237 | if (GROUP.triggerElements.length === 0) { 238 | close() 239 | } else if (currentIndex >= GROUP.triggerElements.length) { 240 | select(GROUP.triggerElements.length - 1) 241 | } else { 242 | updateAttributes() 243 | updateSliderNavigationStatus() 244 | updateCounter() 245 | } 246 | } else if (EL_INDEX < currentIndex) { 247 | currentIndex-- 248 | updateAttributes() 249 | updateSliderNavigationStatus() 250 | updateCounter() 251 | } else { 252 | updateAttributes() 253 | updateSliderNavigationStatus() 254 | updateCounter() 255 | } 256 | } 257 | 258 | // Unbind click event handler 259 | el.removeEventListener('click', triggerParvus) 260 | 261 | el.classList.remove('parvus-trigger') 262 | } 263 | 264 | /** 265 | * Create the lightbox 266 | * 267 | */ 268 | const createLightbox = () => { 269 | // Use DocumentFragment to batch DOM operations 270 | const fragment = document.createDocumentFragment() 271 | 272 | // Create the lightbox container 273 | lightbox = document.createElement('dialog') 274 | lightbox.setAttribute('role', 'dialog') 275 | lightbox.setAttribute('aria-modal', 'true') 276 | lightbox.setAttribute('aria-label', config.l10n.lightboxLabel) 277 | lightbox.classList.add('parvus') 278 | 279 | // Create the lightbox overlay container 280 | lightboxOverlay = document.createElement('div') 281 | lightboxOverlay.classList.add('parvus__overlay') 282 | 283 | // Create the toolbar 284 | toolbar = document.createElement('div') 285 | toolbar.className = 'parvus__toolbar' 286 | 287 | // Create the toolbar items 288 | toolbarLeft = document.createElement('div') 289 | toolbarRight = document.createElement('div') 290 | 291 | // Create the controls 292 | controls = document.createElement('div') 293 | controls.className = 'parvus__controls' 294 | controls.setAttribute('role', 'group') 295 | controls.setAttribute('aria-label', config.l10n.controlsLabel) 296 | 297 | // Create the close button 298 | closeButton = document.createElement('button') 299 | closeButton.className = 'parvus__btn parvus__btn--close' 300 | closeButton.setAttribute('type', 'button') 301 | closeButton.setAttribute('aria-label', config.l10n.closeButtonLabel) 302 | closeButton.innerHTML = config.closeButtonIcon 303 | 304 | // Create the previous button 305 | previousButton = document.createElement('button') 306 | previousButton.className = 'parvus__btn parvus__btn--previous' 307 | previousButton.setAttribute('type', 'button') 308 | previousButton.setAttribute('aria-label', config.l10n.previousButtonLabel) 309 | previousButton.innerHTML = config.previousButtonIcon 310 | 311 | // Create the next button 312 | nextButton = document.createElement('button') 313 | nextButton.className = 'parvus__btn parvus__btn--next' 314 | nextButton.setAttribute('type', 'button') 315 | nextButton.setAttribute('aria-label', config.l10n.nextButtonLabel) 316 | nextButton.innerHTML = config.nextButtonIcon 317 | 318 | // Create the counter 319 | counter = document.createElement('div') 320 | counter.className = 'parvus__counter' 321 | 322 | // Add the control buttons to the controls 323 | controls.append(closeButton, previousButton, nextButton) 324 | 325 | // Add the counter to the left toolbar item 326 | toolbarLeft.appendChild(counter) 327 | 328 | // Add the controls to the right toolbar item 329 | toolbarRight.appendChild(controls) 330 | 331 | // Add the toolbar items to the toolbar 332 | toolbar.append(toolbarLeft, toolbarRight) 333 | 334 | // Add the overlay and the toolbar to the lightbox 335 | lightbox.append(lightboxOverlay, toolbar) 336 | fragment.appendChild(lightbox) 337 | 338 | // Add to document body 339 | document.body.appendChild(fragment) 340 | } 341 | 342 | /** 343 | * Create a slider 344 | * 345 | */ 346 | const createSlider = () => { 347 | const SLIDER = document.createElement('div') 348 | 349 | SLIDER.className = 'parvus__slider' 350 | 351 | // Update the slider reference in GROUPS 352 | GROUPS[activeGroup].slider = SLIDER 353 | 354 | // Add the slider to the lightbox container 355 | lightbox.appendChild(SLIDER) 356 | } 357 | 358 | /** 359 | * Get next slide index 360 | * 361 | * @param {Number} curentIndex - Current slide index 362 | * @returns {number} Index of the next available slide or -1 if none found 363 | */ 364 | const getNextSlideIndex = (currentIndex) => { 365 | const SLIDE_ELEMENTS = GROUPS[activeGroup].sliderElements 366 | const TOTAL_SLIDE_ELEMENTS = SLIDE_ELEMENTS.length 367 | 368 | for (let i = currentIndex + 1; i < TOTAL_SLIDE_ELEMENTS; i++) { 369 | if (SLIDE_ELEMENTS[i] !== undefined) { 370 | return i 371 | } 372 | } 373 | 374 | return -1 375 | } 376 | 377 | /** 378 | * Get previous slide index 379 | * 380 | * @param {number} currentIndex - Current slide index 381 | * @returns {number} Index of the previous available slide or -1 if no found 382 | */ 383 | const getPreviousSlideIndex = (currentIndex) => { 384 | const SLIDE_ELEMENTS = GROUPS[activeGroup].sliderElements 385 | 386 | for (let i = currentIndex - 1; i >= 0; i--) { 387 | if (SLIDE_ELEMENTS[i] !== undefined) { 388 | return i 389 | } 390 | } 391 | 392 | return -1 393 | } 394 | 395 | /** 396 | * Create a slide 397 | * 398 | * @param {Number} index - The index of the slide 399 | */ 400 | const createSlide = (index) => { 401 | if (GROUPS[activeGroup].sliderElements[index] !== undefined) { 402 | return 403 | } 404 | 405 | const FRAGMENT = document.createDocumentFragment() 406 | const SLIDE_ELEMENT = document.createElement('div') 407 | const SLIDE_ELEMENT_CONTENT = document.createElement('div') 408 | 409 | const GROUP = GROUPS[activeGroup] 410 | const TOTAL_TRIGGER_ELEMENTS = GROUP.triggerElements.length 411 | 412 | SLIDE_ELEMENT.className = 'parvus__slide' 413 | SLIDE_ELEMENT.style.cssText = ` 414 | position: absolute; 415 | left: ${index * 100}%; 416 | ` 417 | SLIDE_ELEMENT.setAttribute('aria-hidden', 'true') 418 | 419 | // Add accessibility attributes if gallery has multiple slides 420 | if (TOTAL_TRIGGER_ELEMENTS > 1) { 421 | SLIDE_ELEMENT.setAttribute('role', 'group') 422 | SLIDE_ELEMENT.setAttribute('aria-label', `${config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`) 423 | } 424 | 425 | SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT) 426 | FRAGMENT.appendChild(SLIDE_ELEMENT) 427 | 428 | GROUP.sliderElements[index] = SLIDE_ELEMENT 429 | 430 | // Insert the slide element based on index position 431 | if (index >= currentIndex) { 432 | // Insert the slide element after the current slide 433 | const NEXT_SLIDE_INDEX = getNextSlideIndex(index) 434 | 435 | if (NEXT_SLIDE_INDEX !== -1) { 436 | GROUP.sliderElements[NEXT_SLIDE_INDEX].before(SLIDE_ELEMENT) 437 | } else { 438 | GROUP.slider.appendChild(SLIDE_ELEMENT) 439 | } 440 | } else { 441 | // Insert the slide element before the current slide 442 | const PREVIOUS_SLIDE_INDEX = getPreviousSlideIndex(index) 443 | 444 | if (PREVIOUS_SLIDE_INDEX !== -1) { 445 | GROUP.sliderElements[PREVIOUS_SLIDE_INDEX].after(SLIDE_ELEMENT) 446 | } else { 447 | GROUP.slider.prepend(SLIDE_ELEMENT) 448 | } 449 | } 450 | } 451 | 452 | /** 453 | * Open Parvus 454 | * 455 | * @param {HTMLElement} el 456 | */ 457 | const open = (el) => { 458 | if (!lightbox || !el || !el.classList.contains('parvus-trigger') || isOpen()) { 459 | return 460 | } 461 | 462 | activeGroup = getGroup(el) 463 | 464 | const GROUP = GROUPS[activeGroup] 465 | const EL_INDEX = GROUP.triggerElements.indexOf(el) 466 | 467 | if (EL_INDEX === -1) { 468 | throw new Error('Ups, element not found in group.') 469 | } 470 | 471 | currentIndex = EL_INDEX 472 | 473 | history.pushState({ parvus: 'close' }, 'Image', window.location.href) 474 | 475 | bindEvents() 476 | 477 | if (config.hideScrollbar) { 478 | document.body.style.marginInlineEnd = `${getScrollbarWidth()}px` 479 | document.body.style.overflow = 'hidden' 480 | } 481 | 482 | lightbox.classList.add('parvus--is-opening') 483 | lightbox.showModal() 484 | 485 | createSlider() 486 | createSlide(currentIndex) 487 | 488 | updateOffset() 489 | updateAttributes() 490 | updateSliderNavigationStatus() 491 | updateCounter() 492 | 493 | loadSlide(currentIndex) 494 | 495 | createImage(el, currentIndex, () => { 496 | loadImage(currentIndex, true) 497 | lightbox.classList.remove('parvus--is-opening') 498 | 499 | GROUP.slider.classList.add('parvus__slider--animate') 500 | }) 501 | 502 | preload(currentIndex + 1) 503 | preload(currentIndex - 1) 504 | 505 | // Create and dispatch a new event 506 | dispatchCustomEvent('open') 507 | } 508 | 509 | /** 510 | * Close Parvus 511 | * 512 | */ 513 | const close = () => { 514 | if (!isOpen()) { 515 | return 516 | } 517 | 518 | const IMAGE = GROUPS[activeGroup].contentElements[currentIndex] 519 | const THUMBNAIL = GROUPS[activeGroup].triggerElements[currentIndex] 520 | 521 | unbindEvents() 522 | clearDrag() 523 | 524 | if (history.state?.parvus === 'close') { 525 | history.back() 526 | } 527 | 528 | lightbox.classList.add('parvus--is-closing') 529 | 530 | const transitionendHandler = () => { 531 | // Reset the image zoom (if ESC was pressed or went back in the browser history) 532 | // after the ViewTransition (otherwise it looks bad) 533 | if (isPinching) { 534 | resetZoom(IMAGE) 535 | } 536 | 537 | leaveSlide(currentIndex) 538 | 539 | lightbox.close() 540 | lightbox.classList.remove('parvus--is-closing') 541 | lightbox.classList.remove('parvus--is-vertical-closing') 542 | 543 | GROUPS[activeGroup].slider.remove() 544 | GROUPS[activeGroup].slider = null 545 | GROUPS[activeGroup].sliderElements = [] 546 | GROUPS[activeGroup].contentElements = [] 547 | 548 | counter.removeAttribute('aria-hidden') 549 | 550 | previousButton.removeAttribute('aria-hidden') 551 | previousButton.removeAttribute('aria-disabled') 552 | 553 | nextButton.removeAttribute('aria-hidden') 554 | nextButton.removeAttribute('aria-disabled') 555 | 556 | if (config.hideScrollbar) { 557 | document.body.style.marginInlineEnd = '' 558 | document.body.style.overflow = '' 559 | } 560 | } 561 | 562 | if (IMAGE && IMAGE.tagName === 'IMG') { 563 | if (document.startViewTransition) { 564 | IMAGE.style.viewTransitionName = 'lightboximage' 565 | 566 | const transition = document.startViewTransition(() => { 567 | IMAGE.style.opacity = '0' 568 | IMAGE.style.viewTransitionName = null 569 | 570 | THUMBNAIL.style.viewTransitionName = 'lightboximage' 571 | }) 572 | 573 | transition.finished.finally(() => { 574 | transitionendHandler() 575 | 576 | THUMBNAIL.style.viewTransitionName = null 577 | }) 578 | } else { 579 | IMAGE.style.opacity = '0' 580 | 581 | requestAnimationFrame(transitionendHandler) 582 | } 583 | } else { 584 | transitionendHandler() 585 | } 586 | } 587 | 588 | /** 589 | * Preload slide with the specified index 590 | * 591 | * @param {Number} index - The index of the slide to be preloaded 592 | */ 593 | const preload = (index) => { 594 | if (index < 0 || index >= GROUPS[activeGroup].triggerElements.length || GROUPS[activeGroup].sliderElements[index] !== undefined) { 595 | return 596 | } 597 | 598 | createSlide(index) 599 | createImage(GROUPS[activeGroup].triggerElements[index], index, () => { 600 | loadImage(index) 601 | }) 602 | } 603 | 604 | /** 605 | * Load slide with the specified index 606 | * 607 | * @param {Number} index - The index of the slide to be loaded 608 | */ 609 | const loadSlide = (index) => { 610 | GROUPS[activeGroup].sliderElements[index].setAttribute('aria-hidden', 'false') 611 | } 612 | 613 | /** 614 | * Add caption to the container element 615 | * 616 | * @param {HTMLElement} containerEl - The container element to which the caption will be added 617 | * @param {HTMLElement} imageEl - The image the caption is linked to 618 | * @param {HTMLElement} el - The trigger element associated with the caption 619 | * @param {Number} index - The index of the caption 620 | */ 621 | const addCaption = (containerEl, imageEl, el, index) => { 622 | const CAPTION_CONTAINER = document.createElement('div') 623 | let captionData = null 624 | 625 | CAPTION_CONTAINER.className = 'parvus__caption' 626 | 627 | if (config.captionsSelector === 'self') { 628 | if (el.hasAttribute(config.captionsAttribute) && el.getAttribute(config.captionsAttribute) !== '') { 629 | captionData = el.getAttribute(config.captionsAttribute) 630 | } 631 | } else { 632 | const CAPTION_SELECTOR = el.querySelector(config.captionsSelector) 633 | 634 | if (CAPTION_SELECTOR !== null) { 635 | if (CAPTION_SELECTOR.hasAttribute(config.captionsAttribute) && CAPTION_SELECTOR.getAttribute(config.captionsAttribute) !== '') { 636 | captionData = CAPTION_SELECTOR.getAttribute(config.captionsAttribute) 637 | } else { 638 | captionData = CAPTION_SELECTOR.innerHTML 639 | } 640 | } 641 | } 642 | 643 | if (captionData !== null) { 644 | const CAPTION_ID = `parvus__caption-${index}` 645 | 646 | CAPTION_CONTAINER.id = CAPTION_ID 647 | CAPTION_CONTAINER.innerHTML = `

${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 | Parvus test 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |

Parvus

17 |

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 |
19 | 20 |
21 |

Gallery

22 |

Gallery with the data-group attribute.

23 |
24 |
25 | 28 | Picturesque house facades in Leiden, a city and municipality in the province of South Holland, Netherlands. 32 | 33 |
34 | 35 |
36 | 40 | The river 'Oude Rijn' in Leiden, a city and municipality in the province of South Holland, Netherlands. 45 | 46 |
47 | 48 |
49 | 53 | Picturesque house facades in Leiden, a city and municipality in the province of South Holland, Netherlands. 58 | 59 |
60 |
61 |
62 | 63 |
64 |

Text link

65 |

Text links with the data-group attribute.

66 | 92 |
93 | 94 | 121 | 122 |
123 |

Non-grouped images

124 |
125 |
126 | 128 | 2 glasses filled with Mojito Cocktail on a dark table. 133 | 134 |
135 |
136 |
137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Import Parvus 3 | * 4 | */ 5 | import Parvus from '../dist/js/parvus.esm.min.js' 6 | 7 | /** 8 | * Import language file 9 | * 10 | */ 11 | import de from '../src/l10n/de.js' 12 | 13 | /** 14 | * Initialize Parvus 15 | * 16 | */ 17 | const prvs = new Parvus({ 18 | gallerySelector: '.gallery', 19 | l10n: de 20 | }) 21 | 22 | /** 23 | * API 24 | * 25 | */ 26 | 27 | // Get the index of the currently displayed slide 28 | console.log('Current index: ', prvs.currentIndex()) 29 | 30 | // Add the specified element (DOM element) to Parvus 31 | const newImage = document.querySelector('.lightbox-new') 32 | 33 | prvs.add(newImage) 34 | 35 | /* 36 | setTimeout(() => { 37 | prvs.select(0) 38 | }, 4000) */ 39 | 40 | /** 41 | * Events 42 | * 43 | */ 44 | prvs.on('open', () => { 45 | console.log(`Open: 46 | Index: ${prvs.currentIndex()}, 47 | Slide: ${prvs.currentIndex() + 1}`) 48 | }) 49 | 50 | prvs.on('select', () => { 51 | console.log(`Select: 52 | Index: ${prvs.currentIndex()}, 53 | Slide: ${prvs.currentIndex() + 1}`) 54 | }) 55 | 56 | prvs.on('close', () => { 57 | console.log('Close') 58 | }) 59 | 60 | prvs.on('destroy', () => { 61 | console.log('Destroy') 62 | }) 63 | --------------------------------------------------------------------------------