Repository: deoostfreese/Parvus Branch: main Commit: 3a3ddefc5d5d Files: 32 Total size: 249.1 KB Directory structure: gitextract_wwaefxjx/ ├── .github/ │ └── FUNDING.yml ├── .gitignore ├── .stylelintrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── dist/ │ ├── css/ │ │ └── parvus.css │ └── js/ │ ├── parvus.esm.js │ └── parvus.js ├── package.json ├── rollup.config.js ├── src/ │ ├── js/ │ │ ├── core/ │ │ │ ├── config.js │ │ │ ├── events.js │ │ │ ├── navigation.js │ │ │ ├── plugins.js │ │ │ ├── state.js │ │ │ └── utils.js │ │ ├── handlers/ │ │ │ ├── gestures.js │ │ │ ├── images.js │ │ │ ├── keyboard.js │ │ │ └── pointer.js │ │ ├── helpers/ │ │ │ └── dom.js │ │ ├── parvus.js │ │ └── ui/ │ │ ├── lightbox.js │ │ └── zoom-indicator.js │ ├── l10n/ │ │ ├── de.js │ │ ├── en.js │ │ ├── fr.js │ │ ├── it.js │ │ └── nl.js │ └── scss/ │ └── parvus.scss └── test/ └── test.html ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [deoostfrees] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .gitignore ================================================ .DS_Store .vscode node_modules ================================================ FILE: .stylelintrc ================================================ { "extends": [ "stylelint-config-standard-scss" ], "plugins": [ "stylelint-scss", "stylelint-use-logical" ], "rules": { "at-rule-no-unknown": null, "scss/at-rule-no-unknown": true, "color-hex-length": "long", "comment-whitespace-inside": null, "no-descending-specificity": null, "shorthand-property-no-redundant-values": [true, {"severity": "warning"}], "declaration-no-important": true, "no-duplicate-at-import-rules": true, "selector-max-id": 0, "declaration-block-no-duplicate-properties": true, "rule-empty-line-before": ["always-multi-line", {"ignore": ["after-comment"]}], "value-keyword-case": "lower", "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 }], "declaration-block-no-redundant-longhand-properties": null, "csstools/use-logical": true } } ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [3.1.0] - 2026-04-18 ### Added - Add copyright information to an image e598627 @deoostfrees - Add plugin system with lifecycle hooks ae8203d 2ea0794 86f1056 @deoostfrees - Add support for captions via ID reference f6e1b8c @deoostfrees - Add Italian translations 30c42e2 ea54ca2 @conlaccento - Add French translations 4d04d8d @slolo2000 ### Changed - Use `hsl()` instead of `hsla()` 4ac8cb6 @deoostfrees - Modularize codebase and improve maintainability 951d30a @deoostfrees ## [3.0.0] - 2025-03-16 ### Added - Pinch zoom gestures 4a591e7 4a8355a fd4ebf1 4e472ef 49c5b16 d27efd9 @deoostfrees #42 - Option to make the zoom indicator optional e65d5c7 @deoostfrees #62 ### Changed - Use the native HTML `dialog` element e703293 @deoostfrees #60 - Use the View Transitions API for the zoom in/ out animation 11e183f @deoostfrees - Use pointer events instead of mouse and touch events b4941cf @deoostfrees ### Removed - **Breaking:** The custom event `detail` property 4ea8e38 @deoostfrees - The `transitionDuration` option. This option is now also set via the available CSS custom property 11e183f @deoostfrees - The `transitionTimingFunction` option. This option is now also set via the available CSS custom property 11e183f @deoostfrees - The `loadEmpty` option. The internal `add` function now creates the lightbox 98e41b5 @deoostfrees - The custom `close` event. The native HTML `dialog` element has its own `close` event dba4678 @deoostfrees ## [2.6.0] - 2024-06-05 ### Changed - Run `change` event listener for `reducedMotionCheck` only when the lightbox is open 083a0e7 @deoostfrees ### Fixed - Avoid unintentionally moving the image when dragging 96ff56e @deoostfrees #59 - Relationship between caption and image 76df207 @deoostfrees ## [2.5.3] - 2024-04-27 ### Fixed - Remove optional files field in package.json to include all files via NPM 819e132 @deoostfrees ## [2.5.2] - 2024-04-27 ### Fixed - Language file import afe86dc @deoostfrees #55 ## [2.5.1] - 2024-04-10 ### Fixed - Issue if no language options are set 2dbed4a @deoostfrees ## [2.5.0] - 2024-04-07 ### Added - Option to load an empty lightbox (even if there are no elements) 9a180fc @deoostfrees a436a81 @drhino - Fallback to the default language 39e1ae0 @drhino - Dutch translation 7476426 @drhino ### Changed - **Breaking:** Rename some CSS custom properties 8b43c66 8ba1f00 @deoostfrees ### Removed - Slide animation when first/ last slide is visible 4df766b @deoostfrees #52 ## [2.4.0] - 2023-07-20 ### Added - Option to hide the browser scrollbar #47 ### Changed - Added an internal function to create and dispatch a new event - Disabled buttons are no longer visually hidden - Focus is no longer moved automatically - CSS styles are now moved from SVG to the actual elements ### Removed - Custom typography styles ### Fixed - Load the srcset before the src, add sizes attribute #49 ## [2.3.3] - 2023-05-30 ### Fixed - Animate current image and set focus back to the correct element in the default behavior of the `backFocus` option ## [2.3.2] - 2023-05-30 ### Fixed - Set focus back to the correct element in the default behavior of the `backFocus` option ## [2.3.1] - 2023-05-29 ### Fixed - The navigation buttons' visibility ## [2.3.0] - 2023-05-27 ### Added - Changelog section to keep track of changes - Necessary outputs for screen reader support - CSS custom properties for captions and image loading error messages ### Changed - Replaced the custom `copyObject()` function with the built-in `structuredClone()` method - Refactored code and comments to improve readability and optimize performance ### Removed - The option for supported image file types as it is no longer necessary - The `scrollClose` option ### Fixed - Non standard URLs can break Parvus #43 ================================================ FILE: LICENSE.md ================================================ # The MIT License (MIT) Copyright (c) 2020-2026 Benjamin de Oostfrees 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: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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. ================================================ FILE: README.md ================================================ # Parvus 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. ![Screenshot of Parvus. It shows the first picture of a gallery.](https://rqrauhvmra.com/parvus/parvus-3-1.png) [Open in CodePen](https://codepen.io/collection/DwLBpz) ## Table of Contents - [Installation](#installation) - [Download](#download) - [Package Managers](#package-managers) - [Usage](#usage) - [Captions](#captions) - [Copyright](#copyright) - [Gallery](#gallery) - [Responsive Images](#responsive-images) - [Localization](#localization) - [Options](#options) - [API](#api) - [Events](#events) - [Plugins](#plugins) - [Using Plugins](#using-plugins) - [Creating Plugins](#creating-plugins) - [Plugin Hooks](#plugin-hooks) - [Browser Support](#browser-support) ## Installation ### Download - CSS: - `dist/css/parvus.min.css` (minified) or - `dist/css/parvus.css` (un-minified) - JavaScript: - `dist/js/parvus.min.js` (minified) or - `dist/js/parvus.js` (un-minified) Link the `.css` and `.js` files in your HTML: ```html Page title ``` ### Package Managers You can also install Parvus using npm or yarn: ```sh npm install parvus ``` or ```sh yarn add parvus ``` After installation, import Parvus into your JavaScript codebase: ```js import Parvus from 'parvus' ``` Be sure to include the corresponding SCSS or CSS file. ## Usage Link a thumbnail image with the class `lightbox` to a larger image: ```html ``` Initialize the script: ```js const prvs = new Parvus() ``` ### Captions There are three ways to add a caption to an image: #### Reference by ID You can add an ID to your caption element and reference it from the trigger element using the `data-caption-id` attribute. ```html
I'm a caption, and I live outside the link.
``` #### Direct Attribute You can add a `data-caption` attribute directly to the trigger element. ```html ``` #### Child Element Alternatively, set the option `captionsSelector` to select a caption from a child element's `innerHTML`. ```html
I'm a caption inside a child element
``` ```js const prvs = new Parvus({ captionsSelector: '.figure__caption', }) ``` ### Copyright There are three ways to add copyright information to an image: #### Reference by ID You can add an ID to your copyright element and reference it from the trigger element using the `data-copyright-id` attribute. ```html ``` #### Direct Attribute You can add a `data-copyright` attribute directly to the trigger element. ```html ``` #### Child Element Alternatively, set the option `copyrightSelector` to select a copyright from a child element's `innerHTML`. ```html
© 2026 Photographer Name
``` ```js const prvs = new Parvus({ copyrightSelector: '.figure__copyright', }) ``` ### Gallery To group related images into a set, add a `data-group` attribute: ```html //... ``` Alternatively, set the option `gallerySelector` to group all images with a specific class within a selector: ```html ``` ```js const prvs = new Parvus({ gallerySelector: '.gallery', }) ``` ### Responsive Images Specify different image sources and sizes using the `data-srcset` and `data-sizes` attributes: ```html ``` ### Localization Import the language module and set it as an option for localization: ```js import de from 'parvus/src/l10n/de' const prvs = new Parvus({ l10n: de }) ``` ## Options Customize Parvus by passing an options object when initializing: ```js const prvs = new Parvus({ // Clicking outside does not close Parvus docClose: false }) ``` Available options include: ```js { // Selector for elements that trigger Parvus selector: '.lightbox', // Selector for a group of elements combined as a gallery, overrides the `data-group` attribute. gallerySelector: null, // Display zoom indicator zoomIndicator: true, // Display captions if available captions: true, // Selector for the element where the caption is displayed; use "self" for the `a` tag itself. captionsSelector: 'self', // Attribute to get the caption from captionsAttribute: 'data-caption', // Display copyright if available copyright: true, // Selector for the element where the copyright is displayed; use "self" for the `a` tag itself. copyrightSelector: 'self', // Attribute to get the copyright from copyrightAttribute: 'data-copyright', // Clicking outside closes Parvus docClose: true, // Close Parvus by swiping up/down swipeClose: true, // Accept mouse events like touch events (click and drag to change slides) simulateTouch: true, // Touch dragging threshold in pixels threshold: 100, // Hide browser scrollbar hideScrollbar: true, // Icons lightboxIndicatorIcon: '', previousButtonIcon: '', nextButtonIcon: '', closeButtonIcon: '', // Localization of strings l10n: en } ``` ## API Parvus provides the following API functions: | Function | Description | | --- | --- | | `open(element)` | Open the specified `element` (DOM element) | | `close()` | Close Parvus | | `previous()` | Show the previous image | | `next()` | Show the next image | | `select(index)` | Select a slide with the specified `index` (integer) | | `add(element)` | Add the specified `element` (DOM element) | | `remove(element)` | Remove the specified `element` (DOM element) | | `destroy()` | Destroy Parvus | | `isOpen()` | Check if Parvus is currently open | | `currentIndex()` | Get the index of the currently displayed slide | | `use(plugin, options)` | Register a plugin | | `addHook(hookName, callback)` | Add a hook callback | | `getPlugins()` | Get list of registered plugins | ## Events Bind and unbind events using the `.on()` and `.off()` methods: ```js const prvs = new Parvus() const listener = () => { console.log('eventName happened') } // Bind event listener prvs.on(eventName, listener) // Unbind event listener prvs.off(eventName, listener) ``` Available events: | eventName | Description | | --- | --- | | `open` | Triggered after Parvus has opened | | `select` | Triggered when a slide is selected | | `close` | Triggered after Parvus has closed | | `destroy` | Triggered after Parvus has destroyed | ## Plugins Parvus supports a plugin system that allows you to extend its functionality. ### Using Plugins To use a plugin, call the `.use()` method after initialization: ```js import Parvus from 'parvus' import MyPlugin from './my-plugin.js' const prvs = new Parvus() // Register plugin prvs.use(MyPlugin, { // Plugin-specific options option1: 'value1', option2: 'value2' }) ``` ### Creating Plugins A plugin is an object with a `name` and an `install` function: ```js const MyPlugin = { name: 'MyPlugin', install(parvus, options = {}) { // Plugin initialization code console.log('Plugin installed with options: ', options) } } export default MyPlugin ``` ### Plugin Hooks Plugins can hook into various lifecycle events: | Hook Name | When Triggered | Provided Data | | --- | --- | --- | | `afterInit` | After lightbox DOM is created (once) | `{ state }` | | `afterOpen` | After lightbox opens | `{ element, state }` | | `afterClose` | After lightbox closes | `{ state }` | | `slideChange` | When slide changes | `{ index, oldIndex, state }` | Example using hooks: ```js const MyPlugin = { name: 'MyPlugin', install(parvus, options) { // Add a custom button on init parvus.addHook('afterInit', ({ state }) => { const btn = document.createElement('button') btn.classList.add('parvus__btn') btn.classList.add('parvus__btn--my-plugin') btn.textContent = 'Custom' btn.type = 'button' // Add to controls as first element if (state.controls) { state.controls.prepend(btn) } }) // Track slide changes parvus.addHook('slideChange', ({ index, oldIndex }) => { console.log(`Changed from slide ${oldIndex} to ${index}`) }) } } ``` ## Browser Support Parvus is supported on the latest versions of the following browsers: - Chrome - Edge - Firefox - Safari ================================================ FILE: dist/css/parvus.css ================================================ :root { --parvus-transition-duration: 0.3s; --parvus-transition-timing-function: cubic-bezier(0.62, 0.16, 0.13, 1.01); --parvus-background-color: hsl(23deg 44% 96%); --parvus-color: hsl(228deg 24% 23%); --parvus-btn-background-color: hsl(228deg 24% 23%); --parvus-btn-color: hsl(0deg 0% 100%); --parvus-btn-hover-background-color: hsl(229deg 24% 33%); --parvus-btn-hover-color: hsl(0deg 0% 100%); --parvus-btn-disabled-background-color: hsl(229deg 24% 33% / 60%); --parvus-btn-disabled-color: hsl(0deg 0% 100%); --parvus-caption-background-color: transparent; --parvus-caption-color: hsl(228deg 24% 23%); --parvus-copyright-background-color: hsl(0deg 0% 100% / 80%); --parvus-copyright-color: hsl(228deg 24% 23%); --parvus-loading-error-background-color: hsl(0deg 0% 100%); --parvus-loading-error-color: hsl(228deg 24% 23%); --parvus-loader-background-color: hsl(23deg 40% 96%); --parvus-loader-color: hsl(228deg 24% 23%); } ::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 * */ .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; justify-content: center; padding: 0.5rem; position: absolute; inset-inline-end: 0.5rem; inset-block-start: 0.5rem; } .parvus-trigger:has(img) img { display: block; } /** * Parvus * */ .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 *::before, .parvus *::after { 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 { position: relative; } .parvus__content--error { background-color: var(--parvus-loading-error-background-color); color: var(--parvus-loading-error-color); padding-block: 0.5rem; padding-inline: 1rem; } .parvus__caption { background-color: var(--parvus-caption-background-color); color: var(--parvus-caption-color); padding-block-start: 0.5rem; text-align: start; } .parvus__copyright { background-color: var(--parvus-copyright-background-color); color: var(--parvus-copyright-color); inset-block-end: 0; inset-inline-end: 0; padding-inline: 0.25rem; position: absolute; } .parvus__loader { display: inline-block; block-size: 6.25rem; inset-inline-start: 50%; position: absolute; inset-block-start: 50%; transform: translate(-50%, -50%); inline-size: 6.25rem; } .parvus__loader::before { animation: spin 1s infinite linear; border-radius: 100%; border: 0.25rem solid var(--parvus-loader-background-color); border-block-start-color: var(--parvus-loader-color); content: ""; inset: 0; position: absolute; z-index: 1; } .parvus__toolbar { align-items: center; display: flex; inset-block-start: 1rem; inset-inline: 1rem; justify-content: space-between; pointer-events: none; position: absolute; view-transition-name: toolbar; z-index: 8; } .parvus__toolbar > * { pointer-events: auto; } .parvus__controls { align-items: center; display: flex; gap: 0.5rem; } .parvus__btn { appearance: none; background-color: var(--parvus-btn-background-color); background-image: none; border-radius: 0; border: 0.0625rem solid transparent; color: var(--parvus-btn-color); cursor: pointer; display: flex; font: inherit; padding: 0.3125rem; position: relative; touch-action: manipulation; will-change: transform, opacity; z-index: 7; } .parvus__btn:hover, .parvus__btn:focus-visible { background-color: var(--parvus-btn-hover-background-color); color: var(--parvus-btn-hover-color); } .parvus__btn--previous { inset-inline-start: 0; position: absolute; inset-block-start: calc(50svh - 1rem); transform: translateY(-50%); } .parvus__btn--next { position: absolute; inset-inline-end: 0; inset-block-start: calc(50svh - 1rem); transform: translateY(-50%); } .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__overlay, .parvus__counter, .parvus__btn, .parvus__caption, .parvus__copyright { 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__copyright { transition-delay: var(--parvus-transition-duration); } .parvus--is-closing .parvus__copyright, .parvus--is-vertical-closing .parvus__copyright, .parvus--is-zooming .parvus__copyright { transition-delay: 0s; transition-duration: 0s; } .parvus--is-opening .parvus__overlay, .parvus--is-opening .parvus__counter, .parvus--is-opening .parvus__btn, .parvus--is-opening .parvus__caption, .parvus--is-opening .parvus__copyright, .parvus--is-closing .parvus__overlay, .parvus--is-closing .parvus__counter, .parvus--is-closing .parvus__btn, .parvus--is-closing .parvus__caption, .parvus--is-closing .parvus__copyright { opacity: 0; } .parvus--is-vertical-closing .parvus__counter, .parvus--is-vertical-closing .parvus__btn:not(.parvus__btn--previous, .parvus__btn--next), .parvus--is-zooming .parvus__counter, .parvus--is-zooming .parvus__btn:not(.parvus__btn--previous, .parvus__btn--next) { transform: translateY(-100%); opacity: 0; } .parvus--is-vertical-closing .parvus__btn--previous, .parvus--is-zooming .parvus__btn--previous { transform: translate(-100%, -50%); opacity: 0; } .parvus--is-vertical-closing .parvus__btn--next, .parvus--is-zooming .parvus__btn--next { transform: translate(100%, -50%); opacity: 0; } .parvus--is-vertical-closing .parvus__caption, .parvus--is-zooming .parvus__caption { transform: translateY(100%); opacity: 0; } .parvus--is-vertical-closing .parvus__copyright, .parvus--is-zooming .parvus__copyright { opacity: 0; } } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } ================================================ FILE: dist/js/parvus.esm.js ================================================ /** * Parvus * * @author Benjamin de Oostfrees * @version 3.1.0 * @url https://github.com/deoostfrees/parvus * * MIT license */ const BROWSER_WINDOW = window; /** * Get scrollbar width * * @return {Number} - The scrollbar width */ const getScrollbarWidth = () => { return BROWSER_WINDOW.innerWidth - document.documentElement.clientWidth; }; const FOCUSABLE_ELEMENTS = ['a:not([inert]):not([tabindex^="-"])', 'button:not([inert]):not([tabindex^="-"]):not(:disabled)', '[tabindex]:not([inert]):not([tabindex^="-"])']; /** * Get the focusable children of the given element * * @return {Array} - An array of focusable children */ const getFocusableChildren = targetEl => { return Array.from(targetEl.querySelectorAll(FOCUSABLE_ELEMENTS.join(', '))).filter(child => child.offsetParent !== null); }; var en = { 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' }; /** * Default configuration options */ const DEFAULT_OPTIONS = { selector: '.lightbox', gallerySelector: null, zoomIndicator: true, captions: true, captionsSelector: 'self', captionsAttribute: 'data-caption', copyright: true, copyrightSelector: 'self', copyrightAttribute: 'data-copyright', docClose: true, swipeClose: true, simulateTouch: true, threshold: 50, hideScrollbar: true, lightboxIndicatorIcon: '', previousButtonIcon: '', nextButtonIcon: '', closeButtonIcon: '', l10n: en }; /** * Merge default options with user-provided options * * @param {Object} userOptions - User-provided options * @returns {Object} - Merged options object */ const mergeOptions = userOptions => { const MERGED_OPTIONS = { ...DEFAULT_OPTIONS, ...userOptions }; if (userOptions && userOptions.l10n) { MERGED_OPTIONS.l10n = { ...DEFAULT_OPTIONS.l10n, ...userOptions.l10n }; } return MERGED_OPTIONS; }; /** * State management for Parvus * * Centralizes all mutable state variables */ class ParvusState { constructor() { // Group management this.GROUP_ATTRIBUTES = { triggerElements: [], slider: null, sliderElements: [], contentElements: [] }; this.GROUPS = {}; this.groupIdCounter = 0; this.newGroup = null; this.activeGroup = null; this.currentIndex = 0; // Configuration this.config = {}; // DOM elements this.lightbox = null; this.lightboxOverlay = null; this.lightboxOverlayOpacity = 1; this.toolbar = null; this.toolbarLeft = null; this.toolbarRight = null; this.controls = null; this.previousButton = null; this.nextButton = null; this.closeButton = null; this.counter = null; // Drag & interaction state this.drag = {}; this.isDraggingX = false; this.isDraggingY = false; this.pointerDown = false; this.activePointers = new Map(); // Zoom state this.currentScale = 1; this.isPinching = false; this.isTap = false; this.pinchStartDistance = 0; this.lastPointersId = null; // Offset & animation this.offset = null; this.offsetTmp = null; this.resizeTicking = false; this.isReducedMotion = true; } /** * Clear drag state */ clearDrag() { this.drag = { startX: 0, endX: 0, startY: 0, endY: 0 }; } /** * Get the active group * * @returns {Object} The active group */ getActiveGroup() { return this.GROUPS[this.activeGroup]; } /** * Reset zoom state */ resetZoomState() { this.isPinching = false; this.isTap = false; this.currentScale = 1; this.pinchStartDistance = 0; this.lastPointersId = ''; } } /** * Event System Module * * Handles custom event dispatching and listeners */ /** * Dispatch a custom event * * @param {HTMLElement} lightbox - The lightbox element * @param {String} type - The type of the event to dispatch * @returns {void} */ const dispatchCustomEvent = (lightbox, type) => { const CUSTOM_EVENT = new CustomEvent(type, { cancelable: true }); lightbox.dispatchEvent(CUSTOM_EVENT); }; /** * Bind a specific event listener * * @param {HTMLElement} lightbox - The lightbox element * @param {String} eventName - The name of the event to bind * @param {Function} callback - The callback function * @returns {void} */ const on = (lightbox, eventName, callback) => { if (lightbox) { lightbox.addEventListener(eventName, callback); } }; /** * Unbind a specific event listener * * @param {HTMLElement} lightbox - The lightbox element * @param {String} eventName - The name of the event to unbind * @param {Function} callback - The callback function * @returns {void} */ const off = (lightbox, eventName, callback) => { if (lightbox) { lightbox.removeEventListener(eventName, callback); } }; /** * Navigation Module * * Handles slide navigation and transitions */ /** * Update offset * * @param {Object} state - The application state * @returns {void} */ const updateOffset = state => { state.activeGroup = state.activeGroup !== null ? state.activeGroup : state.newGroup; state.offset = -state.currentIndex * state.lightbox.offsetWidth; state.GROUPS[state.activeGroup].slider.style.transform = `translate3d(${state.offset}px, 0, 0)`; state.offsetTmp = state.offset; }; /** * Load slide with the specified index * * @param {Object} state - The application state * @param {Number} index - The index of the slide to be loaded * @returns {void} */ const loadSlide = (state, index) => { state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'false'); }; /** * Leave slide * * @param {Object} state - The application state * @param {Number} index - The index of the slide to leave * @returns {void} */ const leaveSlide = (state, index) => { if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) { state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'true'); } }; /** * Preload slide with the specified index * * @param {Object} state - The application state * @param {Function} createSlide - Create slide function * @param {Function} createImage - Create image function * @param {Function} loadImage - Load image function * @param {Number} index - The index of the slide to be preloaded * @returns {void} */ const preload = (state, createSlide, createImage, loadImage, index) => { if (index < 0 || index >= state.GROUPS[state.activeGroup].triggerElements.length || state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) { return; } createSlide(state, index); createImage(state, state.GROUPS[state.activeGroup].triggerElements[index], index, () => { loadImage(state, index); }); }; /** * Utils Module * * Utility functions */ /** * Check prefers reduced motion * * @param {Object} state - The application state * @param {MediaQueryList} motionQuery - The media query list * @returns {void} */ const reducedMotionCheck = (state, motionQuery) => { if (motionQuery.matches) { state.isReducedMotion = true; } else { state.isReducedMotion = false; } }; /** * Retrieves or creates a group identifier for the given element * * @param {Object} state - The application state * @param {HTMLElement} el - DOM element to get or assign a group to * @returns {string} The group identifier associated with the element */ const getGroup = (state, el) => { // Return existing group identifier if already assigned if (el.dataset.group) { return el.dataset.group; } // Generate new unique group identifier using counter const EL_GROUP = `default-${state.groupIdCounter++}`; // Assign the new group identifier to element's dataset el.dataset.group = EL_GROUP; return EL_GROUP; }; /** * Plugin management for Parvus * * Provides a system for registering and managing plugins */ class PluginManager { constructor() { this.plugins = []; this.hooks = {}; this.context = null; this.isInitialized = false; } /** * Register a plugin * * @param {Object} plugin - Plugin object with name and install function * @param {Object} options - Plugin-specific options */ register(plugin, options = {}) { if (!plugin || typeof plugin.install !== 'function') { throw new Error('Plugin must have an install function'); } if (!plugin.name) { throw new Error('Plugin must have a name'); } // Check if plugin is already registered const existingPlugin = this.plugins.find(p => p.name === plugin.name); if (existingPlugin) { console.warn(`Plugin "${plugin.name}" is already registered`); return; } this.plugins.push({ plugin, options }); // If already initialized, install immediately if (this.isInitialized && this.context) { this.installPlugin(plugin, options); } } /** * Install a single plugin * * @param {Object} plugin - Plugin object * @param {Object} options - Plugin options */ installPlugin(plugin, options) { try { plugin.install(this.context, options); // If lightbox already exists, execute afterInit hook for this plugin immediately if (this.context && this.context.state && this.context.state.lightbox) { this.executeHook('afterInit', { state: this.context.state }); } } catch (error) { console.error(`Failed to install plugin "${plugin.name}":`, error); } } /** * Install all registered plugins * * @param {Object} context - Parvus instance context */ install(context) { this.context = context; this.isInitialized = true; this.plugins.forEach(({ plugin, options }) => { this.installPlugin(plugin, options); }); } /** * Execute a hook * * @param {String} hookName - Name of the hook * @param {*} data - Data to pass to hook callbacks */ executeHook(hookName, data) { const callbacks = this.hooks[hookName] || []; callbacks.forEach(callback => { try { callback(data); } catch (error) { console.error(`Error in hook "${hookName}":`, error); } }); } /** * Register a hook callback * * @param {String} hookName - Name of the hook * @param {Function} callback - Callback function */ addHook(hookName, callback) { if (!this.hooks[hookName]) { this.hooks[hookName] = []; } this.hooks[hookName].push(callback); } /** * Remove a hook callback * * @param {String} hookName - Name of the hook * @param {Function} callback - Callback function to remove */ removeHook(hookName, callback) { if (!this.hooks[hookName]) return; this.hooks[hookName] = this.hooks[hookName].filter(cb => cb !== callback); } /** * Get all registered plugins * * @returns {Array} Array of plugin names */ getPlugins() { return this.plugins.map(p => p.plugin.name); } } /** * UI Components Module * * Handles creation of lightbox, toolbar, slider and slides */ /** * Create the lightbox * * @param {Object} state - The application state * @returns {void} */ const createLightbox = state => { const { config } = state; // Use DocumentFragment to batch DOM operations const fragment = document.createDocumentFragment(); // Create the lightbox container state.lightbox = document.createElement('dialog'); state.lightbox.setAttribute('role', 'dialog'); state.lightbox.setAttribute('aria-modal', 'true'); state.lightbox.setAttribute('aria-label', config.l10n.lightboxLabel); state.lightbox.classList.add('parvus'); // Create the lightbox overlay container state.lightboxOverlay = document.createElement('div'); state.lightboxOverlay.classList.add('parvus__overlay'); // Create the toolbar state.toolbar = document.createElement('div'); state.toolbar.className = 'parvus__toolbar'; // Create the toolbar items state.toolbarLeft = document.createElement('div'); state.toolbarRight = document.createElement('div'); // Create the controls state.controls = document.createElement('div'); state.controls.className = 'parvus__controls'; state.controls.setAttribute('role', 'group'); state.controls.setAttribute('aria-label', config.l10n.controlsLabel); // Create the close button state.closeButton = document.createElement('button'); state.closeButton.className = 'parvus__btn parvus__btn--close'; state.closeButton.setAttribute('type', 'button'); state.closeButton.setAttribute('aria-label', config.l10n.closeButtonLabel); state.closeButton.innerHTML = config.closeButtonIcon; // Create the previous button state.previousButton = document.createElement('button'); state.previousButton.className = 'parvus__btn parvus__btn--previous'; state.previousButton.setAttribute('type', 'button'); state.previousButton.setAttribute('aria-label', config.l10n.previousButtonLabel); state.previousButton.innerHTML = config.previousButtonIcon; // Create the next button state.nextButton = document.createElement('button'); state.nextButton.className = 'parvus__btn parvus__btn--next'; state.nextButton.setAttribute('type', 'button'); state.nextButton.setAttribute('aria-label', config.l10n.nextButtonLabel); state.nextButton.innerHTML = config.nextButtonIcon; // Create the counter state.counter = document.createElement('div'); state.counter.className = 'parvus__counter'; // Add the control buttons to the controls state.controls.append(state.closeButton, state.previousButton, state.nextButton); // Add the counter to the left toolbar item state.toolbarLeft.appendChild(state.counter); // Add the controls to the right toolbar item state.toolbarRight.appendChild(state.controls); // Add the toolbar items to the toolbar state.toolbar.append(state.toolbarLeft, state.toolbarRight); // Add the overlay and the toolbar to the lightbox state.lightbox.append(state.lightboxOverlay, state.toolbar); fragment.appendChild(state.lightbox); // Add to document body document.body.appendChild(fragment); }; /** * Create a slider * * @param {Object} state - The application state * @returns {void} */ const createSlider = state => { const SLIDER = document.createElement('div'); SLIDER.className = 'parvus__slider'; // Update the slider reference in GROUPS state.GROUPS[state.activeGroup].slider = SLIDER; // Add the slider to the lightbox container state.lightbox.appendChild(SLIDER); }; /** * Get next slide index * * @param {Object} state - The application state * @param {Number} currentIndex - Current slide index * @returns {number} Index of the next available slide or -1 if none found */ const getNextSlideIndex = (state, currentIndex) => { const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements; const TOTAL_SLIDE_ELEMENTS = SLIDE_ELEMENTS.length; for (let i = currentIndex + 1; i < TOTAL_SLIDE_ELEMENTS; i++) { if (SLIDE_ELEMENTS[i] !== undefined) { return i; } } return -1; }; /** * Get previous slide index * * @param {Object} state - The application state * @param {number} currentIndex - Current slide index * @returns {number} Index of the previous available slide or -1 if none found */ const getPreviousSlideIndex = (state, currentIndex) => { const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements; for (let i = currentIndex - 1; i >= 0; i--) { if (SLIDE_ELEMENTS[i] !== undefined) { return i; } } return -1; }; /** * Create a slide * * @param {Object} state - The application state * @param {Number} index - The index of the slide * @returns {void} */ const createSlide = (state, index) => { if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) { return; } const FRAGMENT = document.createDocumentFragment(); const SLIDE_ELEMENT = document.createElement('div'); const SLIDE_ELEMENT_CONTENT = document.createElement('div'); const GROUP = state.GROUPS[state.activeGroup]; const TOTAL_TRIGGER_ELEMENTS = GROUP.triggerElements.length; SLIDE_ELEMENT.className = 'parvus__slide'; SLIDE_ELEMENT.style.cssText = ` position: absolute; left: ${index * 100}%; `; SLIDE_ELEMENT.setAttribute('aria-hidden', 'true'); // Add accessibility attributes if gallery has multiple slides if (TOTAL_TRIGGER_ELEMENTS > 1) { SLIDE_ELEMENT.setAttribute('role', 'group'); SLIDE_ELEMENT.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`); } SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT); FRAGMENT.appendChild(SLIDE_ELEMENT); GROUP.sliderElements[index] = SLIDE_ELEMENT; // Insert the slide element based on index position if (index >= state.currentIndex) { // Insert the slide element after the current slide const NEXT_SLIDE_INDEX = getNextSlideIndex(state, index); if (NEXT_SLIDE_INDEX !== -1) { GROUP.sliderElements[NEXT_SLIDE_INDEX].before(SLIDE_ELEMENT); } else { GROUP.slider.appendChild(SLIDE_ELEMENT); } } else { // Insert the slide element before the current slide const PREVIOUS_SLIDE_INDEX = getPreviousSlideIndex(state, index); if (PREVIOUS_SLIDE_INDEX !== -1) { GROUP.sliderElements[PREVIOUS_SLIDE_INDEX].after(SLIDE_ELEMENT); } else { GROUP.slider.prepend(SLIDE_ELEMENT); } } }; /** * Update counter * * @param {Object} state - The application state * @returns {void} */ const updateCounter = state => { state.counter.textContent = `${state.currentIndex + 1}/${state.GROUPS[state.activeGroup].triggerElements.length}`; }; /** * Update Attributes * * @param {Object} state - The application state * @returns {void} */ const updateAttributes = state => { const TRIGGER_ELEMENTS = state.GROUPS[state.activeGroup].triggerElements; const TOTAL_TRIGGER_ELEMENTS = TRIGGER_ELEMENTS.length; const SLIDER = state.GROUPS[state.activeGroup].slider; const SLIDER_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements; const IS_DRAGGABLE = SLIDER.classList.contains('parvus__slider--is-draggable'); // Add draggable class if necessary if (state.config.simulateTouch && state.config.swipeClose && !IS_DRAGGABLE || state.config.simulateTouch && TOTAL_TRIGGER_ELEMENTS > 1 && !IS_DRAGGABLE) { SLIDER.classList.add('parvus__slider--is-draggable'); } else { SLIDER.classList.remove('parvus__slider--is-draggable'); } // Add extra output for screen reader if there is more than one slide if (TOTAL_TRIGGER_ELEMENTS > 1) { SLIDER.setAttribute('role', 'region'); SLIDER.setAttribute('aria-roledescription', 'carousel'); SLIDER.setAttribute('aria-label', state.config.l10n.sliderLabel); SLIDER_ELEMENTS.forEach((sliderElement, index) => { sliderElement.setAttribute('role', 'group'); sliderElement.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`); }); } else { SLIDER.removeAttribute('role'); SLIDER.removeAttribute('aria-roledescription'); SLIDER.removeAttribute('aria-label'); SLIDER_ELEMENTS.forEach(sliderElement => { sliderElement.removeAttribute('role'); sliderElement.removeAttribute('aria-label'); }); } // Show or hide buttons if (TOTAL_TRIGGER_ELEMENTS === 1) { state.counter.setAttribute('aria-hidden', 'true'); state.previousButton.setAttribute('aria-hidden', 'true'); state.nextButton.setAttribute('aria-hidden', 'true'); } else { state.counter.removeAttribute('aria-hidden'); state.previousButton.removeAttribute('aria-hidden'); state.nextButton.removeAttribute('aria-hidden'); } }; /** * Update slider navigation status * * @param {Object} state - The application state * @returns {void} */ const updateSliderNavigationStatus = state => { const { triggerElements } = state.GROUPS[state.activeGroup]; const TOTAL_TRIGGER_ELEMENTS = triggerElements.length; if (TOTAL_TRIGGER_ELEMENTS <= 1) { return; } // Determine navigation state const FIRST_SLIDE = state.currentIndex === 0; const LAST_SLIDE = state.currentIndex === TOTAL_TRIGGER_ELEMENTS - 1; // Set previous button state const PREV_DISABLED = FIRST_SLIDE ? 'true' : null; if (state.previousButton.getAttribute('aria-disabled') === 'true' !== !!PREV_DISABLED) { PREV_DISABLED ? state.previousButton.setAttribute('aria-disabled', 'true') : state.previousButton.removeAttribute('aria-disabled'); } // Set next button state const NEXT_DISABLED = LAST_SLIDE ? 'true' : null; if (state.nextButton.getAttribute('aria-disabled') === 'true' !== !!NEXT_DISABLED) { NEXT_DISABLED ? state.nextButton.setAttribute('aria-disabled', 'true') : state.nextButton.removeAttribute('aria-disabled'); } }; /** * Add zoom indicator to element * * @param {HTMLElement} el - The element to add the zoom indicator to * @param {Object} config - Options object */ const addZoomIndicator = (el, config) => { if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') === null) { const LIGHTBOX_INDICATOR_ICON = document.createElement('div'); LIGHTBOX_INDICATOR_ICON.className = 'parvus-zoom__indicator'; LIGHTBOX_INDICATOR_ICON.innerHTML = config.lightboxIndicatorIcon; el.appendChild(LIGHTBOX_INDICATOR_ICON); } }; /** * Remove zoom indicator for element * * @param {HTMLElement} el - The element to remove the zoom indicator to */ const removeZoomIndicator = el => { if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') !== null) { const LIGHTBOX_INDICATOR_ICON = el.querySelector('.parvus-zoom__indicator'); el.removeChild(LIGHTBOX_INDICATOR_ICON); } }; /** * Keyboard Event Handler Module * * Handles all keyboard interactions */ /** * Create keyboard event handler * * @param {Object} state - The application state * @param {Object} actions - Actions object with navigation functions * @returns {Function} Keyboard event handler */ const createKeydownHandler = (state, actions) => { return event => { const FOCUSABLE_CHILDREN = getFocusableChildren(state.lightbox); const FOCUSED_ITEM_INDEX = FOCUSABLE_CHILDREN.indexOf(document.activeElement); const lastIndex = FOCUSABLE_CHILDREN.length - 1; switch (event.code) { case 'Tab': { // Use the TAB key to navigate backwards and forwards if (event.shiftKey) { // Navigate backwards if (FOCUSED_ITEM_INDEX === 0) { FOCUSABLE_CHILDREN[lastIndex].focus(); event.preventDefault(); } } else { // Navigate forwards if (FOCUSED_ITEM_INDEX === lastIndex) { FOCUSABLE_CHILDREN[0].focus(); event.preventDefault(); } } break; } case 'Escape': { // Close Parvus when the ESC key is pressed actions.close(); event.preventDefault(); break; } case 'ArrowLeft': { // Show the previous slide when the PREV key is pressed actions.previous(); event.preventDefault(); break; } case 'ArrowRight': { // Show the next slide when the NEXT key is pressed actions.next(); event.preventDefault(); break; } } }; }; /** * Pointer Event Handler Module * * Handles all pointer interactions (mouse, touch, pen) */ /** * Create pointerdown event handler * * @param {Object} state - The application state * @returns {Function} Pointerdown event handler */ const createPointerdownHandler = state => { return event => { event.preventDefault(); event.stopPropagation(); state.isDraggingX = false; state.isDraggingY = false; state.pointerDown = true; state.activePointers.set(event.pointerId, event); state.drag.startX = event.pageX; state.drag.startY = event.pageY; state.drag.endX = event.pageX; state.drag.endY = event.pageY; const { slider } = state.GROUPS[state.activeGroup]; slider.classList.add('parvus__slider--is-dragging'); slider.style.willChange = 'transform'; state.isTap = state.activePointers.size === 1; if (state.config.swipeClose) { state.lightboxOverlayOpacity = getComputedStyle(state.lightboxOverlay).opacity; } }; }; /** * Create pointermove event handler * * @param {Object} state - The application state * @param {Function} pinchZoom - Pinch zoom function * @param {Function} doSwipe - Swipe function * @returns {Function} Pointermove event handler */ const createPointermoveHandler = (state, pinchZoom, doSwipe) => { return event => { event.preventDefault(); if (!state.pointerDown) { return; } const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex]; // Update pointer position state.activePointers.set(event.pointerId, event); // Zoom if (CURRENT_IMAGE && CURRENT_IMAGE.tagName === 'IMG') { if (state.activePointers.size === 2) { pinchZoom(CURRENT_IMAGE); return; } if (state.currentScale > 1) { return; } } state.drag.endX = event.pageX; state.drag.endY = event.pageY; doSwipe(); }; }; /** * Create pointerup event handler * * @param {Object} state - The application state * @param {Function} resetZoom - Reset zoom function * @param {Function} updateAfterDrag - Update after drag function * @returns {Function} Pointerup event handler */ const createPointerupHandler = (state, resetZoom, updateAfterDrag) => { return event => { event.stopPropagation(); const { slider } = state.GROUPS[state.activeGroup]; state.activePointers.delete(event.pointerId); if (state.activePointers.size > 0) { return; } state.pointerDown = false; const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex]; // Reset zoom state by one tap const MOVEMENT_X = Math.abs(state.drag.endX - state.drag.startX); const MOVEMENT_Y = Math.abs(state.drag.endY - state.drag.startY); const IS_TAP = MOVEMENT_X < 8 && MOVEMENT_Y < 8 && !state.isDraggingX && !state.isDraggingY && state.isTap; slider.classList.remove('parvus__slider--is-dragging'); slider.style.willChange = ''; if (state.currentScale > 1) { if (IS_TAP) { resetZoom(CURRENT_IMAGE); } else { CURRENT_IMAGE.style.transform = ` scale(${state.currentScale}) `; } } else { if (state.isPinching) { resetZoom(CURRENT_IMAGE); } if (state.drag.endX || state.drag.endY) { updateAfterDrag(); } } state.clearDrag(); }; }; /** * Create click event handler * * @param {Object} state - The application state * @param {Object} actions - Actions object with navigation functions * @returns {Function} Click event handler */ const createClickHandler = (state, actions) => { return event => { const { target } = event; if (target === state.previousButton) { actions.previous(); } else if (target === state.nextButton) { actions.next(); } else if (target === state.closeButton || state.config.docClose && !state.isDraggingY && !state.isDraggingX && target.classList.contains('parvus__slide')) { actions.close(); } event.stopPropagation(); }; }; /** * Gesture Handler Module * * Handles gestures like pinch-to-zoom and swipe */ /** * Reset image zoom * * @param {Object} state - The application state * @param {HTMLImageElement} currentImg - The image * @returns {void} */ const resetZoom = (state, currentImg) => { currentImg.style.transition = 'transform 0.3s ease'; currentImg.style.transform = ''; setTimeout(() => { currentImg.style.transition = ''; currentImg.style.transformOrigin = ''; }, 300); state.resetZoomState(); state.lightbox.classList.remove('parvus--is-zooming'); }; /** * Pinch zoom gesture * * @param {Object} state - The application state * @param {HTMLImageElement} currentImg - The image to zoom * @returns {void} */ const pinchZoom = (state, currentImg) => { // Determine current finger positions const POINTS = Array.from(state.activePointers.values()); // Calculate current distance between fingers const CURRENT_DISTANCE = Math.hypot(POINTS[1].clientX - POINTS[0].clientX, POINTS[1].clientY - POINTS[0].clientY); // Calculate the midpoint between the two points const MIDPOINT_X = (POINTS[0].clientX + POINTS[1].clientX) / 2; const MIDPOINT_Y = (POINTS[0].clientY + POINTS[1].clientY) / 2; // Convert midpoint to relative position within the image const IMG_RECT = currentImg.getBoundingClientRect(); const RELATIVE_X = (MIDPOINT_X - IMG_RECT.left) / IMG_RECT.width; const RELATIVE_Y = (MIDPOINT_Y - IMG_RECT.top) / IMG_RECT.height; // When pinch gesture is about to start or the finger IDs have changed // Use a unique ID based on the pointer IDs to recognize changes const CURRENT_POINTERS_ID = POINTS.map(p => p.pointerId).sort().join('-'); const IS_NEW_POINTER_COMBINATION = state.lastPointersId !== CURRENT_POINTERS_ID; if (!state.isPinching || IS_NEW_POINTER_COMBINATION) { state.isPinching = true; state.lastPointersId = CURRENT_POINTERS_ID; // Save the start distance and current scaling as a basis state.pinchStartDistance = CURRENT_DISTANCE / state.currentScale; // Store initial pinch position for this gesture if (!currentImg.style.transformOrigin && state.currentScale === 1 || state.currentScale === 1 && IS_NEW_POINTER_COMBINATION) { // Set the transform origin to the pinch midpoint currentImg.style.transformOrigin = `${RELATIVE_X * 100}% ${RELATIVE_Y * 100}%`; } state.lightbox.classList.add('parvus--is-zooming'); } // Calculate scaling factor based on distance change const SCALE_FACTOR = CURRENT_DISTANCE / state.pinchStartDistance; // Limit scaling to 1 - 3 state.currentScale = Math.min(Math.max(1, SCALE_FACTOR), 3); currentImg.style.willChange = 'transform'; currentImg.style.transform = `scale(${state.currentScale})`; }; /** * Determine the swipe direction (horizontal or vertical) * * @param {Object} state - The application state * @returns {void} */ const doSwipe = state => { const MOVEMENT_THRESHOLD = 1.5; const MAX_OPACITY_DISTANCE = 100; const DIRECTION_BIAS = 1.15; const { startX, endX, startY, endY } = state.drag; const MOVEMENT_X = startX - endX; const MOVEMENT_Y = endY - startY; const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X); const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y); const GROUP = state.GROUPS[state.activeGroup]; const SLIDER = GROUP.slider; const TOTAL_SLIDES = GROUP.triggerElements.length; const handleHorizontalSwipe = (movementX, distance) => { const IS_FIRST_SLIDE = state.currentIndex === 0; const IS_LAST_SLIDE = state.currentIndex === TOTAL_SLIDES - 1; const IS_LEFT_SWIPE = movementX > 0; const IS_RIGHT_SWIPE = movementX < 0; if (IS_FIRST_SLIDE && IS_RIGHT_SWIPE || IS_LAST_SLIDE && IS_LEFT_SWIPE) { const DAMPING_FACTOR = 1 / (1 + Math.pow(distance / 100, 0.15)); const REDUCED_MOVEMENT = movementX * DAMPING_FACTOR; SLIDER.style.transform = ` translate3d(${state.offsetTmp - Math.round(REDUCED_MOVEMENT)}px, 0, 0) `; } else { SLIDER.style.transform = ` translate3d(${state.offsetTmp - Math.round(movementX)}px, 0, 0) `; } }; const handleVerticalSwipe = (movementY, distance) => { if (!state.isReducedMotion && distance <= 100) { const NEW_OVERLAY_OPACITY = Math.max(0, state.lightboxOverlayOpacity - distance / MAX_OPACITY_DISTANCE); state.lightboxOverlay.style.opacity = NEW_OVERLAY_OPACITY; } state.lightbox.classList.add('parvus--is-vertical-closing'); SLIDER.style.transform = ` translate3d(${state.offsetTmp}px, ${Math.round(movementY)}px, 0) `; }; if (state.isDraggingX || state.isDraggingY) { if (state.isDraggingX) { handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE); } else if (state.isDraggingY) { handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE); } return; } // Direction detection based on the relative ratio of movements if (MOVEMENT_X_DISTANCE > MOVEMENT_THRESHOLD || MOVEMENT_Y_DISTANCE > MOVEMENT_THRESHOLD) { // Horizontal swipe if X-movement is stronger than Y-movement * DIRECTION_BIAS if (MOVEMENT_X_DISTANCE > MOVEMENT_Y_DISTANCE * DIRECTION_BIAS && TOTAL_SLIDES > 1) { state.isDraggingX = true; state.isDraggingY = false; handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE); } else if (MOVEMENT_Y_DISTANCE > MOVEMENT_X_DISTANCE * DIRECTION_BIAS && state.config.swipeClose) { // Vertical swipe if Y-movement is stronger than X-movement * DIRECTION_BIAS state.isDraggingX = false; state.isDraggingY = true; handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE); } } }; /** * Recalculate drag/swipe event after pointerup * * @param {Object} state - The application state * @param {Object} actions - Navigation actions * @returns {void} */ const updateAfterDrag = (state, actions) => { const { startX, startY, endX, endY } = state.drag; const MOVEMENT_X = endX - startX; const MOVEMENT_Y = endY - startY; const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X); const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y); const { triggerElements } = state.GROUPS[state.activeGroup]; const TOTAL_TRIGGER_ELEMENTS = triggerElements.length; if (state.isDraggingX) { const IS_RIGHT_SWIPE = MOVEMENT_X > 0; if (MOVEMENT_X_DISTANCE >= state.config.threshold) { if (IS_RIGHT_SWIPE && state.currentIndex > 0) { actions.previous(); } else if (!IS_RIGHT_SWIPE && state.currentIndex < TOTAL_TRIGGER_ELEMENTS - 1) { actions.next(); } } actions.updateOffset(); } else if (state.isDraggingY) { if (MOVEMENT_Y_DISTANCE >= state.config.threshold && state.config.swipeClose) { actions.close(); } else { state.lightbox.classList.remove('parvus--is-vertical-closing'); actions.updateOffset(); } state.lightboxOverlay.style.opacity = ''; } else { actions.updateOffset(); } }; /** * Image Handler Module * * Handles image loading, captions, and dimensions */ /** * Add caption to the container element * * @param {Object} config - Configuration object * @param {HTMLElement} containerEl - The container element to which the caption will be added * @param {HTMLElement} imageEl - The image the caption is linked to * @param {HTMLElement} el - The trigger element associated with the caption * @param {Number} index - The index of the caption * @returns {void} */ const addCaption = (config, containerEl, imageEl, el, index) => { const getCaptionData = triggerEl => { const { captionsAttribute, captionsSelector, captionsIdAttribute = 'data-caption-id' } = config; // Check for an ID reference on the trigger element // This allows the caption to be anywhere on the page const CAPTION_ID = triggerEl.getAttribute(captionsIdAttribute); if (CAPTION_ID) { const CAPTION_EL = document.getElementById(CAPTION_ID); if (CAPTION_EL) { return CAPTION_EL.innerHTML; } } // Check for a direct caption attribute on the trigger element const DIRECT_CAPTION = triggerEl.getAttribute(captionsAttribute); if (DIRECT_CAPTION) { return DIRECT_CAPTION; } // Query for a selector inside the trigger element if (captionsSelector !== 'self') { const CAPTION_EL = triggerEl.querySelector(captionsSelector); if (CAPTION_EL) { // Prefer a direct attribute on the found element, otherwise use its content return CAPTION_EL.getAttribute(captionsAttribute) || CAPTION_EL.innerHTML; } } return null; }; const CAPTION_DATA = getCaptionData(el); if (CAPTION_DATA) { const CAPTION_CONTAINER = document.createElement('div'); const CAPTION_ID = `parvus__caption-${index}`; CAPTION_CONTAINER.className = 'parvus__caption'; CAPTION_CONTAINER.id = CAPTION_ID; CAPTION_CONTAINER.innerHTML = `

${CAPTION_DATA}

`; containerEl.appendChild(CAPTION_CONTAINER); imageEl.setAttribute('aria-describedby', CAPTION_ID); } }; /** * Add copyright to the image container element * * @param {Object} config - Configuration object * @param {HTMLElement} imageContainer - The image container element (parvus__content) to which the copyright will be added * @param {HTMLElement} imageEl - The image the copyright is linked to * @param {HTMLElement} el - The trigger element associated with the copyright * @param {Number} index - The index of the copyright * @returns {void} */ const addCopyright = (config, imageContainer, imageEl, el, index) => { const getCopyrightData = triggerEl => { const { copyrightAttribute, copyrightSelector, copyrightIdAttribute = 'data-copyright-id' } = config; // Check for an ID reference on the trigger element // This allows the copyright to be anywhere on the page const COPYRIGHT_ID = triggerEl.getAttribute(copyrightIdAttribute); if (COPYRIGHT_ID) { const COPYRIGHT_EL = document.getElementById(COPYRIGHT_ID); if (COPYRIGHT_EL) { return COPYRIGHT_EL.innerHTML; } } // Check for a direct copyright attribute on the trigger element const DIRECT_COPYRIGHT = triggerEl.getAttribute(copyrightAttribute); if (DIRECT_COPYRIGHT) { return DIRECT_COPYRIGHT; } // Query for a selector inside the trigger element if (copyrightSelector !== 'self') { const COPYRIGHT_EL = triggerEl.querySelector(copyrightSelector); if (COPYRIGHT_EL) { // Prefer a direct attribute on the found element, otherwise use its content return COPYRIGHT_EL.getAttribute(copyrightAttribute) || COPYRIGHT_EL.innerHTML; } } return null; }; const COPYRIGHT_DATA = getCopyrightData(el); if (COPYRIGHT_DATA) { const COPYRIGHT_CONTAINER = document.createElement('div'); const COPYRIGHT_ID = `parvus__copyright-${index}`; COPYRIGHT_CONTAINER.className = 'parvus__copyright'; COPYRIGHT_CONTAINER.id = COPYRIGHT_ID; COPYRIGHT_CONTAINER.innerHTML = `${COPYRIGHT_DATA}`; imageContainer.appendChild(COPYRIGHT_CONTAINER); // If image already has aria-describedby (from caption), append copyright ID const existingAriaDescribedby = imageEl.getAttribute('aria-describedby'); if (existingAriaDescribedby) { imageEl.setAttribute('aria-describedby', `${existingAriaDescribedby} ${COPYRIGHT_ID}`); } else { imageEl.setAttribute('aria-describedby', COPYRIGHT_ID); } } }; /** * Create image * * @param {Object} state - The application state * @param {HTMLElement} el - The trigger element * @param {Number} index - The index * @param {Function} callback - Callback function * @returns {void} */ const createImage = (state, el, index, callback) => { const { contentElements, sliderElements } = state.GROUPS[state.activeGroup]; if (contentElements[index] !== undefined) { if (callback && typeof callback === 'function') { callback(); } return; } const CONTENT_CONTAINER_EL = sliderElements[index].querySelector('div'); const IMAGE = new Image(); const IMAGE_CONTAINER = document.createElement('div'); const THUMBNAIL = el.querySelector('img'); const LOADING_INDICATOR = document.createElement('div'); IMAGE_CONTAINER.className = 'parvus__content'; // Create loading indicator LOADING_INDICATOR.className = 'parvus__loader'; LOADING_INDICATOR.setAttribute('role', 'progressbar'); LOADING_INDICATOR.setAttribute('aria-label', state.config.l10n.lightboxLoadingIndicatorLabel); // Add loading indicator to content container CONTENT_CONTAINER_EL.appendChild(LOADING_INDICATOR); const checkImagePromise = new Promise((resolve, reject) => { IMAGE.onload = () => resolve(IMAGE); IMAGE.onerror = error => reject(error); }); checkImagePromise.then(loadedImage => { loadedImage.style.opacity = 0; IMAGE_CONTAINER.appendChild(loadedImage); // Add copyright if available (inside IMAGE_CONTAINER) if (state.config.copyright) { addCopyright(state.config, IMAGE_CONTAINER, IMAGE, el, index); } CONTENT_CONTAINER_EL.appendChild(IMAGE_CONTAINER); // Add caption if available if (state.config.captions) { addCaption(state.config, CONTENT_CONTAINER_EL, IMAGE, el, index); } contentElements[index] = loadedImage; // Set image width and height loadedImage.setAttribute('width', loadedImage.naturalWidth); loadedImage.setAttribute('height', loadedImage.naturalHeight); // Set image dimension setImageDimension(sliderElements[index], loadedImage); }).catch(() => { const ERROR_CONTAINER = document.createElement('div'); ERROR_CONTAINER.classList.add('parvus__content'); ERROR_CONTAINER.classList.add('parvus__content--error'); ERROR_CONTAINER.textContent = state.config.l10n.lightboxLoadingError; CONTENT_CONTAINER_EL.appendChild(ERROR_CONTAINER); contentElements[index] = ERROR_CONTAINER; }).finally(() => { CONTENT_CONTAINER_EL.removeChild(LOADING_INDICATOR); if (callback && typeof callback === 'function') { callback(); } }); // Add `sizes` attribute if (el.hasAttribute('data-sizes') && el.getAttribute('data-sizes') !== '') { IMAGE.setAttribute('sizes', el.getAttribute('data-sizes')); } // Add `srcset` attribute if (el.hasAttribute('data-srcset') && el.getAttribute('data-srcset') !== '') { IMAGE.setAttribute('srcset', el.getAttribute('data-srcset')); } // Add `src` attribute if (el.tagName === 'A') { IMAGE.setAttribute('src', el.href); } else { IMAGE.setAttribute('src', el.getAttribute('data-target')); } // `alt` attribute if (THUMBNAIL && THUMBNAIL.hasAttribute('alt') && THUMBNAIL.getAttribute('alt') !== '') { IMAGE.alt = THUMBNAIL.alt; } else if (el.hasAttribute('data-alt') && el.getAttribute('data-alt') !== '') { IMAGE.alt = el.getAttribute('data-alt'); } else { IMAGE.alt = ''; } }; /** * Load Image * * @param {Object} state - The application state * @param {Number} index - The index of the image to load * @param {Boolean} animate - Whether to animate the image * @returns {void} */ const loadImage = (state, index, animate) => { const IMAGE = state.GROUPS[state.activeGroup].contentElements[index]; if (IMAGE && IMAGE.tagName === 'IMG') { const THUMBNAIL = state.GROUPS[state.activeGroup].triggerElements[index]; if (animate && document.startViewTransition) { THUMBNAIL.style.viewTransitionName = 'lightboximage'; const transition = document.startViewTransition(() => { IMAGE.style.opacity = ''; THUMBNAIL.style.viewTransitionName = null; IMAGE.style.viewTransitionName = 'lightboximage'; }); transition.finished.finally(() => { IMAGE.style.viewTransitionName = null; }); } else { IMAGE.style.opacity = ''; } } else { IMAGE.style.opacity = ''; } }; /** * Set image dimension * * @param {HTMLElement} slideEl - The slide element * @param {HTMLElement} contentEl - The content element * @returns {void} */ const setImageDimension = (slideEl, contentEl) => { if (contentEl.tagName !== 'IMG') { return; } const SRC_HEIGHT = contentEl.getAttribute('height'); const SRC_WIDTH = contentEl.getAttribute('width'); if (!SRC_HEIGHT || !SRC_WIDTH) { return; } const SLIDE_EL_STYLES = getComputedStyle(slideEl); const HORIZONTAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingLeft) + parseFloat(SLIDE_EL_STYLES.paddingRight); const VERTICAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingTop) + parseFloat(SLIDE_EL_STYLES.paddingBottom); const CAPTION_EL = slideEl.querySelector('.parvus__caption'); const CAPTION_HEIGHT = CAPTION_EL ? CAPTION_EL.getBoundingClientRect().height : 0; const MAX_WIDTH = slideEl.offsetWidth - HORIZONTAL_PADDING; const MAX_HEIGHT = slideEl.offsetHeight - VERTICAL_PADDING - CAPTION_HEIGHT; const RATIO = Math.min(MAX_WIDTH / SRC_WIDTH || 0, MAX_HEIGHT / SRC_HEIGHT || 0); const NEW_WIDTH = SRC_WIDTH * RATIO; const NEW_HEIGHT = SRC_HEIGHT * RATIO; const USE_ORIGINAL_SIZE = SRC_WIDTH <= MAX_WIDTH && SRC_HEIGHT <= MAX_HEIGHT; contentEl.style.width = USE_ORIGINAL_SIZE ? '' : `${NEW_WIDTH}px`; contentEl.style.height = USE_ORIGINAL_SIZE ? '' : `${NEW_HEIGHT}px`; }; /** * Create resize handler * * @param {Object} state - The application state * @param {Function} updateOffset - Update offset function * @returns {Function} Resize event handler */ const createResizeHandler = (state, updateOffset) => { return () => { if (!state.resizeTicking) { state.resizeTicking = true; window.requestAnimationFrame(() => { state.GROUPS[state.activeGroup].sliderElements.forEach((slide, index) => { setImageDimension(slide, state.GROUPS[state.activeGroup].contentElements[index]); }); updateOffset(); state.resizeTicking = false; }); } }; }; // Helper modules /** * Parvus Lightbox * * @param {Object} userOptions - User configuration options * @returns {Object} Parvus instance */ function Parvus(userOptions) { const BROWSER_WINDOW = window; const STATE = new ParvusState(); const MOTIONQUERY = BROWSER_WINDOW.matchMedia('(prefers-reduced-motion)'); const PLUGIN_MANAGER = new PluginManager(); // Event handlers will be created after actions are defined let keydownHandler, clickHandler, pointerdownHandler, pointermoveHandler, pointerupHandler, resizeHandler; /** * Click event handler to trigger Parvus * * @param {Event} event - The click event object */ const triggerParvus = function triggerParvus(event) { event.preventDefault(); open(this); }; /** * Add an element * * @param {HTMLElement} el - The element to be added */ const add = el => { // Check element type and attributes const IS_VALID_LINK = el.tagName === 'A' && el.hasAttribute('href'); const IS_VALID_BUTTON = el.tagName === 'BUTTON' && el.hasAttribute('data-target'); if (!IS_VALID_LINK && !IS_VALID_BUTTON) { 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.'); } // Check if the lightbox already exists if (!STATE.lightbox) { createLightbox(STATE); // Execute afterInit hook when lightbox is first created PLUGIN_MANAGER.executeHook('afterInit', { state: STATE }); } STATE.newGroup = getGroup(STATE, el); if (!STATE.GROUPS[STATE.newGroup]) { STATE.GROUPS[STATE.newGroup] = structuredClone(STATE.GROUP_ATTRIBUTES); } if (STATE.GROUPS[STATE.newGroup].triggerElements.includes(el)) { throw new Error('Ups, element already added.'); } STATE.GROUPS[STATE.newGroup].triggerElements.push(el); if (STATE.config.zoomIndicator) { addZoomIndicator(el, STATE.config); } el.classList.add('parvus-trigger'); el.addEventListener('click', triggerParvus); if (isOpen() && STATE.newGroup === STATE.activeGroup) { const EL_INDEX = STATE.GROUPS[STATE.newGroup].triggerElements.indexOf(el); createSlide(STATE, EL_INDEX); createImage(STATE, el, EL_INDEX, () => { loadImage(STATE, EL_INDEX); }); updateAttributes(STATE); updateSliderNavigationStatus(STATE); updateCounter(STATE); } }; /** * Remove an element * * @param {HTMLElement} el - The element to be removed */ const remove = el => { if (!el || !el.hasAttribute('data-group')) { return; } const EL_GROUP = getGroup(STATE, el); const GROUP = STATE.GROUPS[EL_GROUP]; // Check if element exists if (!GROUP) { return; } const EL_INDEX = GROUP.triggerElements.indexOf(el); if (EL_INDEX === -1) { return; } const IS_CURRENT_EL = isOpen() && EL_GROUP === STATE.activeGroup && EL_INDEX === STATE.currentIndex; // Remove group data if (GROUP.contentElements[EL_INDEX]) { const content = GROUP.contentElements[EL_INDEX]; if (content.tagName === 'IMG') { content.src = ''; content.srcset = ''; } } // Remove DOM element const sliderElement = GROUP.sliderElements[EL_INDEX]; if (sliderElement && sliderElement.parentNode) { sliderElement.parentNode.removeChild(sliderElement); } // Remove all array elements GROUP.triggerElements.splice(EL_INDEX, 1); GROUP.sliderElements.splice(EL_INDEX, 1); GROUP.contentElements.splice(EL_INDEX, 1); if (STATE.config.zoomIndicator) { removeZoomIndicator(el); } if (isOpen() && EL_GROUP === STATE.activeGroup) { if (IS_CURRENT_EL) { if (GROUP.triggerElements.length === 0) { close(); } else if (STATE.currentIndex >= GROUP.triggerElements.length) { select(GROUP.triggerElements.length - 1); } else { updateAttributes(STATE); updateSliderNavigationStatus(STATE); updateCounter(STATE); } } else if (EL_INDEX < STATE.currentIndex) { STATE.currentIndex--; updateAttributes(STATE); updateSliderNavigationStatus(STATE); updateCounter(STATE); } else { updateAttributes(STATE); updateSliderNavigationStatus(STATE); updateCounter(STATE); } } // Unbind click event handler el.removeEventListener('click', triggerParvus); el.classList.remove('parvus-trigger'); }; /** * Open Parvus * * @param {HTMLElement} el */ const open = el => { if (!STATE.lightbox || !el || !el.classList.contains('parvus-trigger') || isOpen()) { return; } STATE.activeGroup = getGroup(STATE, el); const GROUP = STATE.GROUPS[STATE.activeGroup]; const EL_INDEX = GROUP.triggerElements.indexOf(el); if (EL_INDEX === -1) { throw new Error('Ups, element not found in group.'); } STATE.currentIndex = EL_INDEX; history.pushState({ parvus: 'close' }, 'Image', window.location.href); bindEvents(); if (STATE.config.hideScrollbar) { document.body.style.marginInlineEnd = `${getScrollbarWidth()}px`; document.body.style.overflow = 'hidden'; } STATE.lightbox.classList.add('parvus--is-opening'); STATE.lightbox.showModal(); createSlider(STATE); createSlide(STATE, STATE.currentIndex); updateOffset(STATE); updateAttributes(STATE); updateSliderNavigationStatus(STATE); updateCounter(STATE); loadSlide(STATE, STATE.currentIndex); createImage(STATE, el, STATE.currentIndex, () => { loadImage(STATE, STATE.currentIndex, true); STATE.lightbox.classList.remove('parvus--is-opening'); GROUP.slider.classList.add('parvus__slider--animate'); }); preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex + 1); preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex - 1); // Execute afterOpen hook PLUGIN_MANAGER.executeHook('afterOpen', { element: el, state: STATE }); // Create and dispatch a new event dispatchCustomEvent(STATE.lightbox, 'open'); }; /** * Close Parvus */ const close = () => { if (!isOpen()) { return; } const IMAGE = STATE.GROUPS[STATE.activeGroup].contentElements[STATE.currentIndex]; const THUMBNAIL = STATE.GROUPS[STATE.activeGroup].triggerElements[STATE.currentIndex]; unbindEvents(); STATE.clearDrag(); if (history.state?.parvus === 'close') { history.back(); } STATE.lightbox.classList.add('parvus--is-closing'); const transitionendHandler = () => { // Reset the image zoom (if ESC was pressed or went back in the browser history) // after the ViewTransition (otherwise it looks bad) if (STATE.isPinching) { resetZoom(STATE, IMAGE); } leaveSlide(STATE, STATE.currentIndex); STATE.lightbox.close(); STATE.lightbox.classList.remove('parvus--is-closing'); STATE.lightbox.classList.remove('parvus--is-vertical-closing'); STATE.GROUPS[STATE.activeGroup].slider.remove(); STATE.GROUPS[STATE.activeGroup].slider = null; STATE.GROUPS[STATE.activeGroup].sliderElements = []; STATE.GROUPS[STATE.activeGroup].contentElements = []; STATE.counter.removeAttribute('aria-hidden'); STATE.previousButton.removeAttribute('aria-hidden'); STATE.previousButton.removeAttribute('aria-disabled'); STATE.nextButton.removeAttribute('aria-hidden'); STATE.nextButton.removeAttribute('aria-disabled'); if (STATE.config.hideScrollbar) { document.body.style.marginInlineEnd = ''; document.body.style.overflow = ''; } // Execute afterClose hook PLUGIN_MANAGER.executeHook('afterClose', { state: STATE }); }; if (IMAGE && IMAGE.tagName === 'IMG') { if (document.startViewTransition) { IMAGE.style.viewTransitionName = 'lightboximage'; const transition = document.startViewTransition(() => { IMAGE.style.opacity = '0'; IMAGE.style.viewTransitionName = null; THUMBNAIL.style.viewTransitionName = 'lightboximage'; }); transition.finished.finally(() => { transitionendHandler(); THUMBNAIL.style.viewTransitionName = null; }); } else { IMAGE.style.opacity = '0'; requestAnimationFrame(transitionendHandler); } } else { transitionendHandler(); } }; /** * Select a specific slide by index * * @param {number} index - Index of the slide to select */ const select = index => { if (!isOpen()) { throw new Error("Oops, I'm closed."); } if (typeof index !== 'number' || isNaN(index)) { throw new Error('Oops, no slide specified.'); } const GROUP = STATE.GROUPS[STATE.activeGroup]; const triggerElements = GROUP.triggerElements; if (index === STATE.currentIndex) { throw new Error(`Oops, slide ${index} is already selected.`); } if (index < 0 || index >= triggerElements.length) { throw new Error(`Oops, I can't find slide ${index}.`); } const OLD_INDEX = STATE.currentIndex; STATE.currentIndex = index; if (GROUP.sliderElements[index]) { loadSlide(STATE, index); } else { createSlide(STATE, index); createImage(STATE, GROUP.triggerElements[index], index, () => { loadImage(STATE, index); }); loadSlide(STATE, index); } updateOffset(STATE); updateSliderNavigationStatus(STATE); updateCounter(STATE); // Execute slideChange hook PLUGIN_MANAGER.executeHook('slideChange', { index, oldIndex: OLD_INDEX, state: STATE }); if (index < OLD_INDEX) { preload(STATE, createSlide, createImage, loadImage, index - 1); } else { preload(STATE, createSlide, createImage, loadImage, index + 1); } leaveSlide(STATE, OLD_INDEX); // Create and dispatch a new event dispatchCustomEvent(STATE.lightbox, 'select'); }; /** * Select the previous slide */ const previous = () => { if (STATE.currentIndex > 0) { select(STATE.currentIndex - 1); } }; /** * Select the next slide */ const next = () => { const { triggerElements } = STATE.GROUPS[STATE.activeGroup]; if (STATE.currentIndex < triggerElements.length - 1) { select(STATE.currentIndex + 1); } }; /** * Bind specified events */ const bindEvents = () => { const actions = { close, previous, next, updateOffset: () => updateOffset(STATE) }; // Create handlers with state and actions keydownHandler = createKeydownHandler(STATE, actions); clickHandler = createClickHandler(STATE, actions); resizeHandler = createResizeHandler(STATE, () => updateOffset(STATE)); const updateAfterDragHandler = () => updateAfterDrag(STATE, actions); const pinchZoomHandler = img => pinchZoom(STATE, img); const doSwipeHandler = () => doSwipe(STATE); const resetZoomHandler = img => resetZoom(STATE, img); pointerdownHandler = createPointerdownHandler(STATE); pointermoveHandler = createPointermoveHandler(STATE, pinchZoomHandler, doSwipeHandler); pointerupHandler = createPointerupHandler(STATE, resetZoomHandler, updateAfterDragHandler); BROWSER_WINDOW.addEventListener('keydown', keydownHandler); BROWSER_WINDOW.addEventListener('resize', resizeHandler); // Popstate event BROWSER_WINDOW.addEventListener('popstate', close); // Check for any OS level changes to the prefers reduced motion preference MOTIONQUERY.addEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY)); // Click event STATE.lightbox.addEventListener('click', clickHandler); // Pointer events STATE.lightbox.addEventListener('pointerdown', pointerdownHandler, { passive: false }); STATE.lightbox.addEventListener('pointerup', pointerupHandler, { passive: true }); STATE.lightbox.addEventListener('pointermove', pointermoveHandler, { passive: false }); }; /** * Unbind specified events */ const unbindEvents = () => { BROWSER_WINDOW.removeEventListener('keydown', keydownHandler); BROWSER_WINDOW.removeEventListener('resize', resizeHandler); // Popstate event BROWSER_WINDOW.removeEventListener('popstate', close); // Check for any OS level changes to the prefers reduced motion preference MOTIONQUERY.removeEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY)); // Click event STATE.lightbox.removeEventListener('click', clickHandler); // Pointer events STATE.lightbox.removeEventListener('pointerdown', pointerdownHandler); STATE.lightbox.removeEventListener('pointerup', pointerupHandler); STATE.lightbox.removeEventListener('pointermove', pointermoveHandler); }; /** * Destroy Parvus */ const destroy = () => { if (!STATE.lightbox) { return; } if (isOpen()) { close(); } // Add setTimeout to ensure all possible close transitions are completed setTimeout(() => { unbindEvents(); // Remove all registered event listeners for custom events const eventTypes = ['open', 'close', 'select', 'destroy']; eventTypes.forEach(eventType => { const listeners = STATE.lightbox._listeners?.[eventType] || []; listeners.forEach(listener => { STATE.lightbox.removeEventListener(eventType, listener); }); }); // Remove event listeners from trigger elements const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll('.parvus-trigger'); LIGHTBOX_TRIGGER_ELS.forEach(el => { el.removeEventListener('click', triggerParvus); el.classList.remove('parvus-trigger'); if (STATE.config.zoomIndicator) { removeZoomIndicator(el); } if (el.dataset.group) { delete el.dataset.group; } }); // Create and dispatch a new event dispatchCustomEvent(STATE.lightbox, 'destroy'); STATE.lightbox.remove(); // Remove references STATE.lightbox = null; STATE.lightboxOverlay = null; STATE.toolbar = null; STATE.toolbarLeft = null; STATE.toolbarRight = null; STATE.controls = null; STATE.previousButton = null; STATE.nextButton = null; STATE.closeButton = null; STATE.counter = null; // Remove group data Object.keys(STATE.GROUPS).forEach(groupKey => { const group = STATE.GROUPS[groupKey]; if (group && group.contentElements) { group.contentElements.forEach(content => { if (content && content.tagName === 'IMG') { content.src = ''; content.srcset = ''; } }); } delete STATE.GROUPS[groupKey]; }); // Reset variables STATE.groupIdCounter = 0; STATE.newGroup = null; STATE.activeGroup = null; STATE.currentIndex = 0; }, 1000); }; /** * Check if Parvus is open * * @returns {boolean} - True if Parvus is open, otherwise false */ const isOpen = () => { return STATE.lightbox?.hasAttribute('open'); }; /** * Get the current index * * @returns {number} - The current index */ const getCurrentIndex = () => { return STATE.currentIndex; }; /** * Bind a specific event listener * * @param {String} eventName - The name of the event to bind * @param {Function} callback - The callback function */ const on$1 = (eventName, callback) => { on(STATE.lightbox, eventName, callback); }; /** * Unbind a specific event listener * * @param {String} eventName - The name of the event to unbind * @param {Function} callback - The callback function */ const off$1 = (eventName, callback) => { off(STATE.lightbox, eventName, callback); }; /** * Use a plugin * * @param {Object} plugin - Plugin object * @param {Object} options - Plugin options */ const use = (plugin, options = {}) => { PLUGIN_MANAGER.register(plugin, options); }; /** * Add a hook callback * * @param {String} hookName - Hook name * @param {Function} callback - Callback function */ const addHook = (hookName, callback) => { PLUGIN_MANAGER.addHook(hookName, callback); }; /** * Get registered plugins * * @returns {Array} Array of plugin names */ const getPlugins = () => { return PLUGIN_MANAGER.getPlugins(); }; /** * Init */ const init = () => { // Merge user options into defaults STATE.config = mergeOptions(userOptions); reducedMotionCheck(STATE, MOTIONQUERY); // Install plugins with context const pluginContext = { state: STATE, on: on, addHook: PLUGIN_MANAGER.addHook.bind(PLUGIN_MANAGER), config: STATE.config }; PLUGIN_MANAGER.install(pluginContext); if (STATE.config.gallerySelector !== null) { // Get a list of all `gallerySelector` elements within the document const GALLERY_ELS = document.querySelectorAll(STATE.config.gallerySelector); // Execute a few things once per element GALLERY_ELS.forEach((galleryEl, index) => { const GALLERY_INDEX = index; // Get a list of all `selector` elements within the `gallerySelector` const LIGHTBOX_TRIGGER_GALLERY_ELS = galleryEl.querySelectorAll(STATE.config.selector); // Execute a few things once per element LIGHTBOX_TRIGGER_GALLERY_ELS.forEach(lightboxTriggerEl => { lightboxTriggerEl.setAttribute('data-group', `parvus-gallery-${GALLERY_INDEX}`); add(lightboxTriggerEl); }); }); } // Get a list of all `selector` elements outside or without the `gallerySelector` const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(`${STATE.config.selector}:not(.parvus-trigger)`); LIGHTBOX_TRIGGER_ELS.forEach(add); }; init(); return { init, open, close, select, previous, next, currentIndex: getCurrentIndex, add, remove, destroy, isOpen, on: on$1, off: off$1, use, addHook, getPlugins }; } export { Parvus as default }; ================================================ FILE: dist/js/parvus.js ================================================ /** * Parvus * * @author Benjamin de Oostfrees * @version 3.1.0 * @url https://github.com/deoostfrees/parvus * * MIT license */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Parvus = factory()); })(this, (function () { 'use strict'; const BROWSER_WINDOW = window; /** * Get scrollbar width * * @return {Number} - The scrollbar width */ const getScrollbarWidth = () => { return BROWSER_WINDOW.innerWidth - document.documentElement.clientWidth; }; const FOCUSABLE_ELEMENTS = ['a:not([inert]):not([tabindex^="-"])', 'button:not([inert]):not([tabindex^="-"]):not(:disabled)', '[tabindex]:not([inert]):not([tabindex^="-"])']; /** * Get the focusable children of the given element * * @return {Array} - An array of focusable children */ const getFocusableChildren = targetEl => { return Array.from(targetEl.querySelectorAll(FOCUSABLE_ELEMENTS.join(', '))).filter(child => child.offsetParent !== null); }; var en = { 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' }; /** * Default configuration options */ const DEFAULT_OPTIONS = { selector: '.lightbox', gallerySelector: null, zoomIndicator: true, captions: true, captionsSelector: 'self', captionsAttribute: 'data-caption', copyright: true, copyrightSelector: 'self', copyrightAttribute: 'data-copyright', docClose: true, swipeClose: true, simulateTouch: true, threshold: 50, hideScrollbar: true, lightboxIndicatorIcon: '', previousButtonIcon: '', nextButtonIcon: '', closeButtonIcon: '', l10n: en }; /** * Merge default options with user-provided options * * @param {Object} userOptions - User-provided options * @returns {Object} - Merged options object */ const mergeOptions = userOptions => { const MERGED_OPTIONS = { ...DEFAULT_OPTIONS, ...userOptions }; if (userOptions && userOptions.l10n) { MERGED_OPTIONS.l10n = { ...DEFAULT_OPTIONS.l10n, ...userOptions.l10n }; } return MERGED_OPTIONS; }; /** * State management for Parvus * * Centralizes all mutable state variables */ class ParvusState { constructor() { // Group management this.GROUP_ATTRIBUTES = { triggerElements: [], slider: null, sliderElements: [], contentElements: [] }; this.GROUPS = {}; this.groupIdCounter = 0; this.newGroup = null; this.activeGroup = null; this.currentIndex = 0; // Configuration this.config = {}; // DOM elements this.lightbox = null; this.lightboxOverlay = null; this.lightboxOverlayOpacity = 1; this.toolbar = null; this.toolbarLeft = null; this.toolbarRight = null; this.controls = null; this.previousButton = null; this.nextButton = null; this.closeButton = null; this.counter = null; // Drag & interaction state this.drag = {}; this.isDraggingX = false; this.isDraggingY = false; this.pointerDown = false; this.activePointers = new Map(); // Zoom state this.currentScale = 1; this.isPinching = false; this.isTap = false; this.pinchStartDistance = 0; this.lastPointersId = null; // Offset & animation this.offset = null; this.offsetTmp = null; this.resizeTicking = false; this.isReducedMotion = true; } /** * Clear drag state */ clearDrag() { this.drag = { startX: 0, endX: 0, startY: 0, endY: 0 }; } /** * Get the active group * * @returns {Object} The active group */ getActiveGroup() { return this.GROUPS[this.activeGroup]; } /** * Reset zoom state */ resetZoomState() { this.isPinching = false; this.isTap = false; this.currentScale = 1; this.pinchStartDistance = 0; this.lastPointersId = ''; } } /** * Event System Module * * Handles custom event dispatching and listeners */ /** * Dispatch a custom event * * @param {HTMLElement} lightbox - The lightbox element * @param {String} type - The type of the event to dispatch * @returns {void} */ const dispatchCustomEvent = (lightbox, type) => { const CUSTOM_EVENT = new CustomEvent(type, { cancelable: true }); lightbox.dispatchEvent(CUSTOM_EVENT); }; /** * Bind a specific event listener * * @param {HTMLElement} lightbox - The lightbox element * @param {String} eventName - The name of the event to bind * @param {Function} callback - The callback function * @returns {void} */ const on = (lightbox, eventName, callback) => { if (lightbox) { lightbox.addEventListener(eventName, callback); } }; /** * Unbind a specific event listener * * @param {HTMLElement} lightbox - The lightbox element * @param {String} eventName - The name of the event to unbind * @param {Function} callback - The callback function * @returns {void} */ const off = (lightbox, eventName, callback) => { if (lightbox) { lightbox.removeEventListener(eventName, callback); } }; /** * Navigation Module * * Handles slide navigation and transitions */ /** * Update offset * * @param {Object} state - The application state * @returns {void} */ const updateOffset = state => { state.activeGroup = state.activeGroup !== null ? state.activeGroup : state.newGroup; state.offset = -state.currentIndex * state.lightbox.offsetWidth; state.GROUPS[state.activeGroup].slider.style.transform = `translate3d(${state.offset}px, 0, 0)`; state.offsetTmp = state.offset; }; /** * Load slide with the specified index * * @param {Object} state - The application state * @param {Number} index - The index of the slide to be loaded * @returns {void} */ const loadSlide = (state, index) => { state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'false'); }; /** * Leave slide * * @param {Object} state - The application state * @param {Number} index - The index of the slide to leave * @returns {void} */ const leaveSlide = (state, index) => { if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) { state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'true'); } }; /** * Preload slide with the specified index * * @param {Object} state - The application state * @param {Function} createSlide - Create slide function * @param {Function} createImage - Create image function * @param {Function} loadImage - Load image function * @param {Number} index - The index of the slide to be preloaded * @returns {void} */ const preload = (state, createSlide, createImage, loadImage, index) => { if (index < 0 || index >= state.GROUPS[state.activeGroup].triggerElements.length || state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) { return; } createSlide(state, index); createImage(state, state.GROUPS[state.activeGroup].triggerElements[index], index, () => { loadImage(state, index); }); }; /** * Utils Module * * Utility functions */ /** * Check prefers reduced motion * * @param {Object} state - The application state * @param {MediaQueryList} motionQuery - The media query list * @returns {void} */ const reducedMotionCheck = (state, motionQuery) => { if (motionQuery.matches) { state.isReducedMotion = true; } else { state.isReducedMotion = false; } }; /** * Retrieves or creates a group identifier for the given element * * @param {Object} state - The application state * @param {HTMLElement} el - DOM element to get or assign a group to * @returns {string} The group identifier associated with the element */ const getGroup = (state, el) => { // Return existing group identifier if already assigned if (el.dataset.group) { return el.dataset.group; } // Generate new unique group identifier using counter const EL_GROUP = `default-${state.groupIdCounter++}`; // Assign the new group identifier to element's dataset el.dataset.group = EL_GROUP; return EL_GROUP; }; /** * Plugin management for Parvus * * Provides a system for registering and managing plugins */ class PluginManager { constructor() { this.plugins = []; this.hooks = {}; this.context = null; this.isInitialized = false; } /** * Register a plugin * * @param {Object} plugin - Plugin object with name and install function * @param {Object} options - Plugin-specific options */ register(plugin, options = {}) { if (!plugin || typeof plugin.install !== 'function') { throw new Error('Plugin must have an install function'); } if (!plugin.name) { throw new Error('Plugin must have a name'); } // Check if plugin is already registered const existingPlugin = this.plugins.find(p => p.name === plugin.name); if (existingPlugin) { console.warn(`Plugin "${plugin.name}" is already registered`); return; } this.plugins.push({ plugin, options }); // If already initialized, install immediately if (this.isInitialized && this.context) { this.installPlugin(plugin, options); } } /** * Install a single plugin * * @param {Object} plugin - Plugin object * @param {Object} options - Plugin options */ installPlugin(plugin, options) { try { plugin.install(this.context, options); // If lightbox already exists, execute afterInit hook for this plugin immediately if (this.context && this.context.state && this.context.state.lightbox) { this.executeHook('afterInit', { state: this.context.state }); } } catch (error) { console.error(`Failed to install plugin "${plugin.name}":`, error); } } /** * Install all registered plugins * * @param {Object} context - Parvus instance context */ install(context) { this.context = context; this.isInitialized = true; this.plugins.forEach(({ plugin, options }) => { this.installPlugin(plugin, options); }); } /** * Execute a hook * * @param {String} hookName - Name of the hook * @param {*} data - Data to pass to hook callbacks */ executeHook(hookName, data) { const callbacks = this.hooks[hookName] || []; callbacks.forEach(callback => { try { callback(data); } catch (error) { console.error(`Error in hook "${hookName}":`, error); } }); } /** * Register a hook callback * * @param {String} hookName - Name of the hook * @param {Function} callback - Callback function */ addHook(hookName, callback) { if (!this.hooks[hookName]) { this.hooks[hookName] = []; } this.hooks[hookName].push(callback); } /** * Remove a hook callback * * @param {String} hookName - Name of the hook * @param {Function} callback - Callback function to remove */ removeHook(hookName, callback) { if (!this.hooks[hookName]) return; this.hooks[hookName] = this.hooks[hookName].filter(cb => cb !== callback); } /** * Get all registered plugins * * @returns {Array} Array of plugin names */ getPlugins() { return this.plugins.map(p => p.plugin.name); } } /** * UI Components Module * * Handles creation of lightbox, toolbar, slider and slides */ /** * Create the lightbox * * @param {Object} state - The application state * @returns {void} */ const createLightbox = state => { const { config } = state; // Use DocumentFragment to batch DOM operations const fragment = document.createDocumentFragment(); // Create the lightbox container state.lightbox = document.createElement('dialog'); state.lightbox.setAttribute('role', 'dialog'); state.lightbox.setAttribute('aria-modal', 'true'); state.lightbox.setAttribute('aria-label', config.l10n.lightboxLabel); state.lightbox.classList.add('parvus'); // Create the lightbox overlay container state.lightboxOverlay = document.createElement('div'); state.lightboxOverlay.classList.add('parvus__overlay'); // Create the toolbar state.toolbar = document.createElement('div'); state.toolbar.className = 'parvus__toolbar'; // Create the toolbar items state.toolbarLeft = document.createElement('div'); state.toolbarRight = document.createElement('div'); // Create the controls state.controls = document.createElement('div'); state.controls.className = 'parvus__controls'; state.controls.setAttribute('role', 'group'); state.controls.setAttribute('aria-label', config.l10n.controlsLabel); // Create the close button state.closeButton = document.createElement('button'); state.closeButton.className = 'parvus__btn parvus__btn--close'; state.closeButton.setAttribute('type', 'button'); state.closeButton.setAttribute('aria-label', config.l10n.closeButtonLabel); state.closeButton.innerHTML = config.closeButtonIcon; // Create the previous button state.previousButton = document.createElement('button'); state.previousButton.className = 'parvus__btn parvus__btn--previous'; state.previousButton.setAttribute('type', 'button'); state.previousButton.setAttribute('aria-label', config.l10n.previousButtonLabel); state.previousButton.innerHTML = config.previousButtonIcon; // Create the next button state.nextButton = document.createElement('button'); state.nextButton.className = 'parvus__btn parvus__btn--next'; state.nextButton.setAttribute('type', 'button'); state.nextButton.setAttribute('aria-label', config.l10n.nextButtonLabel); state.nextButton.innerHTML = config.nextButtonIcon; // Create the counter state.counter = document.createElement('div'); state.counter.className = 'parvus__counter'; // Add the control buttons to the controls state.controls.append(state.closeButton, state.previousButton, state.nextButton); // Add the counter to the left toolbar item state.toolbarLeft.appendChild(state.counter); // Add the controls to the right toolbar item state.toolbarRight.appendChild(state.controls); // Add the toolbar items to the toolbar state.toolbar.append(state.toolbarLeft, state.toolbarRight); // Add the overlay and the toolbar to the lightbox state.lightbox.append(state.lightboxOverlay, state.toolbar); fragment.appendChild(state.lightbox); // Add to document body document.body.appendChild(fragment); }; /** * Create a slider * * @param {Object} state - The application state * @returns {void} */ const createSlider = state => { const SLIDER = document.createElement('div'); SLIDER.className = 'parvus__slider'; // Update the slider reference in GROUPS state.GROUPS[state.activeGroup].slider = SLIDER; // Add the slider to the lightbox container state.lightbox.appendChild(SLIDER); }; /** * Get next slide index * * @param {Object} state - The application state * @param {Number} currentIndex - Current slide index * @returns {number} Index of the next available slide or -1 if none found */ const getNextSlideIndex = (state, currentIndex) => { const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements; const TOTAL_SLIDE_ELEMENTS = SLIDE_ELEMENTS.length; for (let i = currentIndex + 1; i < TOTAL_SLIDE_ELEMENTS; i++) { if (SLIDE_ELEMENTS[i] !== undefined) { return i; } } return -1; }; /** * Get previous slide index * * @param {Object} state - The application state * @param {number} currentIndex - Current slide index * @returns {number} Index of the previous available slide or -1 if none found */ const getPreviousSlideIndex = (state, currentIndex) => { const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements; for (let i = currentIndex - 1; i >= 0; i--) { if (SLIDE_ELEMENTS[i] !== undefined) { return i; } } return -1; }; /** * Create a slide * * @param {Object} state - The application state * @param {Number} index - The index of the slide * @returns {void} */ const createSlide = (state, index) => { if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) { return; } const FRAGMENT = document.createDocumentFragment(); const SLIDE_ELEMENT = document.createElement('div'); const SLIDE_ELEMENT_CONTENT = document.createElement('div'); const GROUP = state.GROUPS[state.activeGroup]; const TOTAL_TRIGGER_ELEMENTS = GROUP.triggerElements.length; SLIDE_ELEMENT.className = 'parvus__slide'; SLIDE_ELEMENT.style.cssText = ` position: absolute; left: ${index * 100}%; `; SLIDE_ELEMENT.setAttribute('aria-hidden', 'true'); // Add accessibility attributes if gallery has multiple slides if (TOTAL_TRIGGER_ELEMENTS > 1) { SLIDE_ELEMENT.setAttribute('role', 'group'); SLIDE_ELEMENT.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`); } SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT); FRAGMENT.appendChild(SLIDE_ELEMENT); GROUP.sliderElements[index] = SLIDE_ELEMENT; // Insert the slide element based on index position if (index >= state.currentIndex) { // Insert the slide element after the current slide const NEXT_SLIDE_INDEX = getNextSlideIndex(state, index); if (NEXT_SLIDE_INDEX !== -1) { GROUP.sliderElements[NEXT_SLIDE_INDEX].before(SLIDE_ELEMENT); } else { GROUP.slider.appendChild(SLIDE_ELEMENT); } } else { // Insert the slide element before the current slide const PREVIOUS_SLIDE_INDEX = getPreviousSlideIndex(state, index); if (PREVIOUS_SLIDE_INDEX !== -1) { GROUP.sliderElements[PREVIOUS_SLIDE_INDEX].after(SLIDE_ELEMENT); } else { GROUP.slider.prepend(SLIDE_ELEMENT); } } }; /** * Update counter * * @param {Object} state - The application state * @returns {void} */ const updateCounter = state => { state.counter.textContent = `${state.currentIndex + 1}/${state.GROUPS[state.activeGroup].triggerElements.length}`; }; /** * Update Attributes * * @param {Object} state - The application state * @returns {void} */ const updateAttributes = state => { const TRIGGER_ELEMENTS = state.GROUPS[state.activeGroup].triggerElements; const TOTAL_TRIGGER_ELEMENTS = TRIGGER_ELEMENTS.length; const SLIDER = state.GROUPS[state.activeGroup].slider; const SLIDER_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements; const IS_DRAGGABLE = SLIDER.classList.contains('parvus__slider--is-draggable'); // Add draggable class if necessary if (state.config.simulateTouch && state.config.swipeClose && !IS_DRAGGABLE || state.config.simulateTouch && TOTAL_TRIGGER_ELEMENTS > 1 && !IS_DRAGGABLE) { SLIDER.classList.add('parvus__slider--is-draggable'); } else { SLIDER.classList.remove('parvus__slider--is-draggable'); } // Add extra output for screen reader if there is more than one slide if (TOTAL_TRIGGER_ELEMENTS > 1) { SLIDER.setAttribute('role', 'region'); SLIDER.setAttribute('aria-roledescription', 'carousel'); SLIDER.setAttribute('aria-label', state.config.l10n.sliderLabel); SLIDER_ELEMENTS.forEach((sliderElement, index) => { sliderElement.setAttribute('role', 'group'); sliderElement.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`); }); } else { SLIDER.removeAttribute('role'); SLIDER.removeAttribute('aria-roledescription'); SLIDER.removeAttribute('aria-label'); SLIDER_ELEMENTS.forEach(sliderElement => { sliderElement.removeAttribute('role'); sliderElement.removeAttribute('aria-label'); }); } // Show or hide buttons if (TOTAL_TRIGGER_ELEMENTS === 1) { state.counter.setAttribute('aria-hidden', 'true'); state.previousButton.setAttribute('aria-hidden', 'true'); state.nextButton.setAttribute('aria-hidden', 'true'); } else { state.counter.removeAttribute('aria-hidden'); state.previousButton.removeAttribute('aria-hidden'); state.nextButton.removeAttribute('aria-hidden'); } }; /** * Update slider navigation status * * @param {Object} state - The application state * @returns {void} */ const updateSliderNavigationStatus = state => { const { triggerElements } = state.GROUPS[state.activeGroup]; const TOTAL_TRIGGER_ELEMENTS = triggerElements.length; if (TOTAL_TRIGGER_ELEMENTS <= 1) { return; } // Determine navigation state const FIRST_SLIDE = state.currentIndex === 0; const LAST_SLIDE = state.currentIndex === TOTAL_TRIGGER_ELEMENTS - 1; // Set previous button state const PREV_DISABLED = FIRST_SLIDE ? 'true' : null; if (state.previousButton.getAttribute('aria-disabled') === 'true' !== !!PREV_DISABLED) { PREV_DISABLED ? state.previousButton.setAttribute('aria-disabled', 'true') : state.previousButton.removeAttribute('aria-disabled'); } // Set next button state const NEXT_DISABLED = LAST_SLIDE ? 'true' : null; if (state.nextButton.getAttribute('aria-disabled') === 'true' !== !!NEXT_DISABLED) { NEXT_DISABLED ? state.nextButton.setAttribute('aria-disabled', 'true') : state.nextButton.removeAttribute('aria-disabled'); } }; /** * Add zoom indicator to element * * @param {HTMLElement} el - The element to add the zoom indicator to * @param {Object} config - Options object */ const addZoomIndicator = (el, config) => { if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') === null) { const LIGHTBOX_INDICATOR_ICON = document.createElement('div'); LIGHTBOX_INDICATOR_ICON.className = 'parvus-zoom__indicator'; LIGHTBOX_INDICATOR_ICON.innerHTML = config.lightboxIndicatorIcon; el.appendChild(LIGHTBOX_INDICATOR_ICON); } }; /** * Remove zoom indicator for element * * @param {HTMLElement} el - The element to remove the zoom indicator to */ const removeZoomIndicator = el => { if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') !== null) { const LIGHTBOX_INDICATOR_ICON = el.querySelector('.parvus-zoom__indicator'); el.removeChild(LIGHTBOX_INDICATOR_ICON); } }; /** * Keyboard Event Handler Module * * Handles all keyboard interactions */ /** * Create keyboard event handler * * @param {Object} state - The application state * @param {Object} actions - Actions object with navigation functions * @returns {Function} Keyboard event handler */ const createKeydownHandler = (state, actions) => { return event => { const FOCUSABLE_CHILDREN = getFocusableChildren(state.lightbox); const FOCUSED_ITEM_INDEX = FOCUSABLE_CHILDREN.indexOf(document.activeElement); const lastIndex = FOCUSABLE_CHILDREN.length - 1; switch (event.code) { case 'Tab': { // Use the TAB key to navigate backwards and forwards if (event.shiftKey) { // Navigate backwards if (FOCUSED_ITEM_INDEX === 0) { FOCUSABLE_CHILDREN[lastIndex].focus(); event.preventDefault(); } } else { // Navigate forwards if (FOCUSED_ITEM_INDEX === lastIndex) { FOCUSABLE_CHILDREN[0].focus(); event.preventDefault(); } } break; } case 'Escape': { // Close Parvus when the ESC key is pressed actions.close(); event.preventDefault(); break; } case 'ArrowLeft': { // Show the previous slide when the PREV key is pressed actions.previous(); event.preventDefault(); break; } case 'ArrowRight': { // Show the next slide when the NEXT key is pressed actions.next(); event.preventDefault(); break; } } }; }; /** * Pointer Event Handler Module * * Handles all pointer interactions (mouse, touch, pen) */ /** * Create pointerdown event handler * * @param {Object} state - The application state * @returns {Function} Pointerdown event handler */ const createPointerdownHandler = state => { return event => { event.preventDefault(); event.stopPropagation(); state.isDraggingX = false; state.isDraggingY = false; state.pointerDown = true; state.activePointers.set(event.pointerId, event); state.drag.startX = event.pageX; state.drag.startY = event.pageY; state.drag.endX = event.pageX; state.drag.endY = event.pageY; const { slider } = state.GROUPS[state.activeGroup]; slider.classList.add('parvus__slider--is-dragging'); slider.style.willChange = 'transform'; state.isTap = state.activePointers.size === 1; if (state.config.swipeClose) { state.lightboxOverlayOpacity = getComputedStyle(state.lightboxOverlay).opacity; } }; }; /** * Create pointermove event handler * * @param {Object} state - The application state * @param {Function} pinchZoom - Pinch zoom function * @param {Function} doSwipe - Swipe function * @returns {Function} Pointermove event handler */ const createPointermoveHandler = (state, pinchZoom, doSwipe) => { return event => { event.preventDefault(); if (!state.pointerDown) { return; } const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex]; // Update pointer position state.activePointers.set(event.pointerId, event); // Zoom if (CURRENT_IMAGE && CURRENT_IMAGE.tagName === 'IMG') { if (state.activePointers.size === 2) { pinchZoom(CURRENT_IMAGE); return; } if (state.currentScale > 1) { return; } } state.drag.endX = event.pageX; state.drag.endY = event.pageY; doSwipe(); }; }; /** * Create pointerup event handler * * @param {Object} state - The application state * @param {Function} resetZoom - Reset zoom function * @param {Function} updateAfterDrag - Update after drag function * @returns {Function} Pointerup event handler */ const createPointerupHandler = (state, resetZoom, updateAfterDrag) => { return event => { event.stopPropagation(); const { slider } = state.GROUPS[state.activeGroup]; state.activePointers.delete(event.pointerId); if (state.activePointers.size > 0) { return; } state.pointerDown = false; const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex]; // Reset zoom state by one tap const MOVEMENT_X = Math.abs(state.drag.endX - state.drag.startX); const MOVEMENT_Y = Math.abs(state.drag.endY - state.drag.startY); const IS_TAP = MOVEMENT_X < 8 && MOVEMENT_Y < 8 && !state.isDraggingX && !state.isDraggingY && state.isTap; slider.classList.remove('parvus__slider--is-dragging'); slider.style.willChange = ''; if (state.currentScale > 1) { if (IS_TAP) { resetZoom(CURRENT_IMAGE); } else { CURRENT_IMAGE.style.transform = ` scale(${state.currentScale}) `; } } else { if (state.isPinching) { resetZoom(CURRENT_IMAGE); } if (state.drag.endX || state.drag.endY) { updateAfterDrag(); } } state.clearDrag(); }; }; /** * Create click event handler * * @param {Object} state - The application state * @param {Object} actions - Actions object with navigation functions * @returns {Function} Click event handler */ const createClickHandler = (state, actions) => { return event => { const { target } = event; if (target === state.previousButton) { actions.previous(); } else if (target === state.nextButton) { actions.next(); } else if (target === state.closeButton || state.config.docClose && !state.isDraggingY && !state.isDraggingX && target.classList.contains('parvus__slide')) { actions.close(); } event.stopPropagation(); }; }; /** * Gesture Handler Module * * Handles gestures like pinch-to-zoom and swipe */ /** * Reset image zoom * * @param {Object} state - The application state * @param {HTMLImageElement} currentImg - The image * @returns {void} */ const resetZoom = (state, currentImg) => { currentImg.style.transition = 'transform 0.3s ease'; currentImg.style.transform = ''; setTimeout(() => { currentImg.style.transition = ''; currentImg.style.transformOrigin = ''; }, 300); state.resetZoomState(); state.lightbox.classList.remove('parvus--is-zooming'); }; /** * Pinch zoom gesture * * @param {Object} state - The application state * @param {HTMLImageElement} currentImg - The image to zoom * @returns {void} */ const pinchZoom = (state, currentImg) => { // Determine current finger positions const POINTS = Array.from(state.activePointers.values()); // Calculate current distance between fingers const CURRENT_DISTANCE = Math.hypot(POINTS[1].clientX - POINTS[0].clientX, POINTS[1].clientY - POINTS[0].clientY); // Calculate the midpoint between the two points const MIDPOINT_X = (POINTS[0].clientX + POINTS[1].clientX) / 2; const MIDPOINT_Y = (POINTS[0].clientY + POINTS[1].clientY) / 2; // Convert midpoint to relative position within the image const IMG_RECT = currentImg.getBoundingClientRect(); const RELATIVE_X = (MIDPOINT_X - IMG_RECT.left) / IMG_RECT.width; const RELATIVE_Y = (MIDPOINT_Y - IMG_RECT.top) / IMG_RECT.height; // When pinch gesture is about to start or the finger IDs have changed // Use a unique ID based on the pointer IDs to recognize changes const CURRENT_POINTERS_ID = POINTS.map(p => p.pointerId).sort().join('-'); const IS_NEW_POINTER_COMBINATION = state.lastPointersId !== CURRENT_POINTERS_ID; if (!state.isPinching || IS_NEW_POINTER_COMBINATION) { state.isPinching = true; state.lastPointersId = CURRENT_POINTERS_ID; // Save the start distance and current scaling as a basis state.pinchStartDistance = CURRENT_DISTANCE / state.currentScale; // Store initial pinch position for this gesture if (!currentImg.style.transformOrigin && state.currentScale === 1 || state.currentScale === 1 && IS_NEW_POINTER_COMBINATION) { // Set the transform origin to the pinch midpoint currentImg.style.transformOrigin = `${RELATIVE_X * 100}% ${RELATIVE_Y * 100}%`; } state.lightbox.classList.add('parvus--is-zooming'); } // Calculate scaling factor based on distance change const SCALE_FACTOR = CURRENT_DISTANCE / state.pinchStartDistance; // Limit scaling to 1 - 3 state.currentScale = Math.min(Math.max(1, SCALE_FACTOR), 3); currentImg.style.willChange = 'transform'; currentImg.style.transform = `scale(${state.currentScale})`; }; /** * Determine the swipe direction (horizontal or vertical) * * @param {Object} state - The application state * @returns {void} */ const doSwipe = state => { const MOVEMENT_THRESHOLD = 1.5; const MAX_OPACITY_DISTANCE = 100; const DIRECTION_BIAS = 1.15; const { startX, endX, startY, endY } = state.drag; const MOVEMENT_X = startX - endX; const MOVEMENT_Y = endY - startY; const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X); const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y); const GROUP = state.GROUPS[state.activeGroup]; const SLIDER = GROUP.slider; const TOTAL_SLIDES = GROUP.triggerElements.length; const handleHorizontalSwipe = (movementX, distance) => { const IS_FIRST_SLIDE = state.currentIndex === 0; const IS_LAST_SLIDE = state.currentIndex === TOTAL_SLIDES - 1; const IS_LEFT_SWIPE = movementX > 0; const IS_RIGHT_SWIPE = movementX < 0; if (IS_FIRST_SLIDE && IS_RIGHT_SWIPE || IS_LAST_SLIDE && IS_LEFT_SWIPE) { const DAMPING_FACTOR = 1 / (1 + Math.pow(distance / 100, 0.15)); const REDUCED_MOVEMENT = movementX * DAMPING_FACTOR; SLIDER.style.transform = ` translate3d(${state.offsetTmp - Math.round(REDUCED_MOVEMENT)}px, 0, 0) `; } else { SLIDER.style.transform = ` translate3d(${state.offsetTmp - Math.round(movementX)}px, 0, 0) `; } }; const handleVerticalSwipe = (movementY, distance) => { if (!state.isReducedMotion && distance <= 100) { const NEW_OVERLAY_OPACITY = Math.max(0, state.lightboxOverlayOpacity - distance / MAX_OPACITY_DISTANCE); state.lightboxOverlay.style.opacity = NEW_OVERLAY_OPACITY; } state.lightbox.classList.add('parvus--is-vertical-closing'); SLIDER.style.transform = ` translate3d(${state.offsetTmp}px, ${Math.round(movementY)}px, 0) `; }; if (state.isDraggingX || state.isDraggingY) { if (state.isDraggingX) { handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE); } else if (state.isDraggingY) { handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE); } return; } // Direction detection based on the relative ratio of movements if (MOVEMENT_X_DISTANCE > MOVEMENT_THRESHOLD || MOVEMENT_Y_DISTANCE > MOVEMENT_THRESHOLD) { // Horizontal swipe if X-movement is stronger than Y-movement * DIRECTION_BIAS if (MOVEMENT_X_DISTANCE > MOVEMENT_Y_DISTANCE * DIRECTION_BIAS && TOTAL_SLIDES > 1) { state.isDraggingX = true; state.isDraggingY = false; handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE); } else if (MOVEMENT_Y_DISTANCE > MOVEMENT_X_DISTANCE * DIRECTION_BIAS && state.config.swipeClose) { // Vertical swipe if Y-movement is stronger than X-movement * DIRECTION_BIAS state.isDraggingX = false; state.isDraggingY = true; handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE); } } }; /** * Recalculate drag/swipe event after pointerup * * @param {Object} state - The application state * @param {Object} actions - Navigation actions * @returns {void} */ const updateAfterDrag = (state, actions) => { const { startX, startY, endX, endY } = state.drag; const MOVEMENT_X = endX - startX; const MOVEMENT_Y = endY - startY; const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X); const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y); const { triggerElements } = state.GROUPS[state.activeGroup]; const TOTAL_TRIGGER_ELEMENTS = triggerElements.length; if (state.isDraggingX) { const IS_RIGHT_SWIPE = MOVEMENT_X > 0; if (MOVEMENT_X_DISTANCE >= state.config.threshold) { if (IS_RIGHT_SWIPE && state.currentIndex > 0) { actions.previous(); } else if (!IS_RIGHT_SWIPE && state.currentIndex < TOTAL_TRIGGER_ELEMENTS - 1) { actions.next(); } } actions.updateOffset(); } else if (state.isDraggingY) { if (MOVEMENT_Y_DISTANCE >= state.config.threshold && state.config.swipeClose) { actions.close(); } else { state.lightbox.classList.remove('parvus--is-vertical-closing'); actions.updateOffset(); } state.lightboxOverlay.style.opacity = ''; } else { actions.updateOffset(); } }; /** * Image Handler Module * * Handles image loading, captions, and dimensions */ /** * Add caption to the container element * * @param {Object} config - Configuration object * @param {HTMLElement} containerEl - The container element to which the caption will be added * @param {HTMLElement} imageEl - The image the caption is linked to * @param {HTMLElement} el - The trigger element associated with the caption * @param {Number} index - The index of the caption * @returns {void} */ const addCaption = (config, containerEl, imageEl, el, index) => { const getCaptionData = triggerEl => { const { captionsAttribute, captionsSelector, captionsIdAttribute = 'data-caption-id' } = config; // Check for an ID reference on the trigger element // This allows the caption to be anywhere on the page const CAPTION_ID = triggerEl.getAttribute(captionsIdAttribute); if (CAPTION_ID) { const CAPTION_EL = document.getElementById(CAPTION_ID); if (CAPTION_EL) { return CAPTION_EL.innerHTML; } } // Check for a direct caption attribute on the trigger element const DIRECT_CAPTION = triggerEl.getAttribute(captionsAttribute); if (DIRECT_CAPTION) { return DIRECT_CAPTION; } // Query for a selector inside the trigger element if (captionsSelector !== 'self') { const CAPTION_EL = triggerEl.querySelector(captionsSelector); if (CAPTION_EL) { // Prefer a direct attribute on the found element, otherwise use its content return CAPTION_EL.getAttribute(captionsAttribute) || CAPTION_EL.innerHTML; } } return null; }; const CAPTION_DATA = getCaptionData(el); if (CAPTION_DATA) { const CAPTION_CONTAINER = document.createElement('div'); const CAPTION_ID = `parvus__caption-${index}`; CAPTION_CONTAINER.className = 'parvus__caption'; CAPTION_CONTAINER.id = CAPTION_ID; CAPTION_CONTAINER.innerHTML = `

${CAPTION_DATA}

`; containerEl.appendChild(CAPTION_CONTAINER); imageEl.setAttribute('aria-describedby', CAPTION_ID); } }; /** * Add copyright to the image container element * * @param {Object} config - Configuration object * @param {HTMLElement} imageContainer - The image container element (parvus__content) to which the copyright will be added * @param {HTMLElement} imageEl - The image the copyright is linked to * @param {HTMLElement} el - The trigger element associated with the copyright * @param {Number} index - The index of the copyright * @returns {void} */ const addCopyright = (config, imageContainer, imageEl, el, index) => { const getCopyrightData = triggerEl => { const { copyrightAttribute, copyrightSelector, copyrightIdAttribute = 'data-copyright-id' } = config; // Check for an ID reference on the trigger element // This allows the copyright to be anywhere on the page const COPYRIGHT_ID = triggerEl.getAttribute(copyrightIdAttribute); if (COPYRIGHT_ID) { const COPYRIGHT_EL = document.getElementById(COPYRIGHT_ID); if (COPYRIGHT_EL) { return COPYRIGHT_EL.innerHTML; } } // Check for a direct copyright attribute on the trigger element const DIRECT_COPYRIGHT = triggerEl.getAttribute(copyrightAttribute); if (DIRECT_COPYRIGHT) { return DIRECT_COPYRIGHT; } // Query for a selector inside the trigger element if (copyrightSelector !== 'self') { const COPYRIGHT_EL = triggerEl.querySelector(copyrightSelector); if (COPYRIGHT_EL) { // Prefer a direct attribute on the found element, otherwise use its content return COPYRIGHT_EL.getAttribute(copyrightAttribute) || COPYRIGHT_EL.innerHTML; } } return null; }; const COPYRIGHT_DATA = getCopyrightData(el); if (COPYRIGHT_DATA) { const COPYRIGHT_CONTAINER = document.createElement('div'); const COPYRIGHT_ID = `parvus__copyright-${index}`; COPYRIGHT_CONTAINER.className = 'parvus__copyright'; COPYRIGHT_CONTAINER.id = COPYRIGHT_ID; COPYRIGHT_CONTAINER.innerHTML = `${COPYRIGHT_DATA}`; imageContainer.appendChild(COPYRIGHT_CONTAINER); // If image already has aria-describedby (from caption), append copyright ID const existingAriaDescribedby = imageEl.getAttribute('aria-describedby'); if (existingAriaDescribedby) { imageEl.setAttribute('aria-describedby', `${existingAriaDescribedby} ${COPYRIGHT_ID}`); } else { imageEl.setAttribute('aria-describedby', COPYRIGHT_ID); } } }; /** * Create image * * @param {Object} state - The application state * @param {HTMLElement} el - The trigger element * @param {Number} index - The index * @param {Function} callback - Callback function * @returns {void} */ const createImage = (state, el, index, callback) => { const { contentElements, sliderElements } = state.GROUPS[state.activeGroup]; if (contentElements[index] !== undefined) { if (callback && typeof callback === 'function') { callback(); } return; } const CONTENT_CONTAINER_EL = sliderElements[index].querySelector('div'); const IMAGE = new Image(); const IMAGE_CONTAINER = document.createElement('div'); const THUMBNAIL = el.querySelector('img'); const LOADING_INDICATOR = document.createElement('div'); IMAGE_CONTAINER.className = 'parvus__content'; // Create loading indicator LOADING_INDICATOR.className = 'parvus__loader'; LOADING_INDICATOR.setAttribute('role', 'progressbar'); LOADING_INDICATOR.setAttribute('aria-label', state.config.l10n.lightboxLoadingIndicatorLabel); // Add loading indicator to content container CONTENT_CONTAINER_EL.appendChild(LOADING_INDICATOR); const checkImagePromise = new Promise((resolve, reject) => { IMAGE.onload = () => resolve(IMAGE); IMAGE.onerror = error => reject(error); }); checkImagePromise.then(loadedImage => { loadedImage.style.opacity = 0; IMAGE_CONTAINER.appendChild(loadedImage); // Add copyright if available (inside IMAGE_CONTAINER) if (state.config.copyright) { addCopyright(state.config, IMAGE_CONTAINER, IMAGE, el, index); } CONTENT_CONTAINER_EL.appendChild(IMAGE_CONTAINER); // Add caption if available if (state.config.captions) { addCaption(state.config, CONTENT_CONTAINER_EL, IMAGE, el, index); } contentElements[index] = loadedImage; // Set image width and height loadedImage.setAttribute('width', loadedImage.naturalWidth); loadedImage.setAttribute('height', loadedImage.naturalHeight); // Set image dimension setImageDimension(sliderElements[index], loadedImage); }).catch(() => { const ERROR_CONTAINER = document.createElement('div'); ERROR_CONTAINER.classList.add('parvus__content'); ERROR_CONTAINER.classList.add('parvus__content--error'); ERROR_CONTAINER.textContent = state.config.l10n.lightboxLoadingError; CONTENT_CONTAINER_EL.appendChild(ERROR_CONTAINER); contentElements[index] = ERROR_CONTAINER; }).finally(() => { CONTENT_CONTAINER_EL.removeChild(LOADING_INDICATOR); if (callback && typeof callback === 'function') { callback(); } }); // Add `sizes` attribute if (el.hasAttribute('data-sizes') && el.getAttribute('data-sizes') !== '') { IMAGE.setAttribute('sizes', el.getAttribute('data-sizes')); } // Add `srcset` attribute if (el.hasAttribute('data-srcset') && el.getAttribute('data-srcset') !== '') { IMAGE.setAttribute('srcset', el.getAttribute('data-srcset')); } // Add `src` attribute if (el.tagName === 'A') { IMAGE.setAttribute('src', el.href); } else { IMAGE.setAttribute('src', el.getAttribute('data-target')); } // `alt` attribute if (THUMBNAIL && THUMBNAIL.hasAttribute('alt') && THUMBNAIL.getAttribute('alt') !== '') { IMAGE.alt = THUMBNAIL.alt; } else if (el.hasAttribute('data-alt') && el.getAttribute('data-alt') !== '') { IMAGE.alt = el.getAttribute('data-alt'); } else { IMAGE.alt = ''; } }; /** * Load Image * * @param {Object} state - The application state * @param {Number} index - The index of the image to load * @param {Boolean} animate - Whether to animate the image * @returns {void} */ const loadImage = (state, index, animate) => { const IMAGE = state.GROUPS[state.activeGroup].contentElements[index]; if (IMAGE && IMAGE.tagName === 'IMG') { const THUMBNAIL = state.GROUPS[state.activeGroup].triggerElements[index]; if (animate && document.startViewTransition) { THUMBNAIL.style.viewTransitionName = 'lightboximage'; const transition = document.startViewTransition(() => { IMAGE.style.opacity = ''; THUMBNAIL.style.viewTransitionName = null; IMAGE.style.viewTransitionName = 'lightboximage'; }); transition.finished.finally(() => { IMAGE.style.viewTransitionName = null; }); } else { IMAGE.style.opacity = ''; } } else { IMAGE.style.opacity = ''; } }; /** * Set image dimension * * @param {HTMLElement} slideEl - The slide element * @param {HTMLElement} contentEl - The content element * @returns {void} */ const setImageDimension = (slideEl, contentEl) => { if (contentEl.tagName !== 'IMG') { return; } const SRC_HEIGHT = contentEl.getAttribute('height'); const SRC_WIDTH = contentEl.getAttribute('width'); if (!SRC_HEIGHT || !SRC_WIDTH) { return; } const SLIDE_EL_STYLES = getComputedStyle(slideEl); const HORIZONTAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingLeft) + parseFloat(SLIDE_EL_STYLES.paddingRight); const VERTICAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingTop) + parseFloat(SLIDE_EL_STYLES.paddingBottom); const CAPTION_EL = slideEl.querySelector('.parvus__caption'); const CAPTION_HEIGHT = CAPTION_EL ? CAPTION_EL.getBoundingClientRect().height : 0; const MAX_WIDTH = slideEl.offsetWidth - HORIZONTAL_PADDING; const MAX_HEIGHT = slideEl.offsetHeight - VERTICAL_PADDING - CAPTION_HEIGHT; const RATIO = Math.min(MAX_WIDTH / SRC_WIDTH || 0, MAX_HEIGHT / SRC_HEIGHT || 0); const NEW_WIDTH = SRC_WIDTH * RATIO; const NEW_HEIGHT = SRC_HEIGHT * RATIO; const USE_ORIGINAL_SIZE = SRC_WIDTH <= MAX_WIDTH && SRC_HEIGHT <= MAX_HEIGHT; contentEl.style.width = USE_ORIGINAL_SIZE ? '' : `${NEW_WIDTH}px`; contentEl.style.height = USE_ORIGINAL_SIZE ? '' : `${NEW_HEIGHT}px`; }; /** * Create resize handler * * @param {Object} state - The application state * @param {Function} updateOffset - Update offset function * @returns {Function} Resize event handler */ const createResizeHandler = (state, updateOffset) => { return () => { if (!state.resizeTicking) { state.resizeTicking = true; window.requestAnimationFrame(() => { state.GROUPS[state.activeGroup].sliderElements.forEach((slide, index) => { setImageDimension(slide, state.GROUPS[state.activeGroup].contentElements[index]); }); updateOffset(); state.resizeTicking = false; }); } }; }; // Helper modules /** * Parvus Lightbox * * @param {Object} userOptions - User configuration options * @returns {Object} Parvus instance */ function Parvus(userOptions) { const BROWSER_WINDOW = window; const STATE = new ParvusState(); const MOTIONQUERY = BROWSER_WINDOW.matchMedia('(prefers-reduced-motion)'); const PLUGIN_MANAGER = new PluginManager(); // Event handlers will be created after actions are defined let keydownHandler, clickHandler, pointerdownHandler, pointermoveHandler, pointerupHandler, resizeHandler; /** * Click event handler to trigger Parvus * * @param {Event} event - The click event object */ const triggerParvus = function triggerParvus(event) { event.preventDefault(); open(this); }; /** * Add an element * * @param {HTMLElement} el - The element to be added */ const add = el => { // Check element type and attributes const IS_VALID_LINK = el.tagName === 'A' && el.hasAttribute('href'); const IS_VALID_BUTTON = el.tagName === 'BUTTON' && el.hasAttribute('data-target'); if (!IS_VALID_LINK && !IS_VALID_BUTTON) { 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.'); } // Check if the lightbox already exists if (!STATE.lightbox) { createLightbox(STATE); // Execute afterInit hook when lightbox is first created PLUGIN_MANAGER.executeHook('afterInit', { state: STATE }); } STATE.newGroup = getGroup(STATE, el); if (!STATE.GROUPS[STATE.newGroup]) { STATE.GROUPS[STATE.newGroup] = structuredClone(STATE.GROUP_ATTRIBUTES); } if (STATE.GROUPS[STATE.newGroup].triggerElements.includes(el)) { throw new Error('Ups, element already added.'); } STATE.GROUPS[STATE.newGroup].triggerElements.push(el); if (STATE.config.zoomIndicator) { addZoomIndicator(el, STATE.config); } el.classList.add('parvus-trigger'); el.addEventListener('click', triggerParvus); if (isOpen() && STATE.newGroup === STATE.activeGroup) { const EL_INDEX = STATE.GROUPS[STATE.newGroup].triggerElements.indexOf(el); createSlide(STATE, EL_INDEX); createImage(STATE, el, EL_INDEX, () => { loadImage(STATE, EL_INDEX); }); updateAttributes(STATE); updateSliderNavigationStatus(STATE); updateCounter(STATE); } }; /** * Remove an element * * @param {HTMLElement} el - The element to be removed */ const remove = el => { if (!el || !el.hasAttribute('data-group')) { return; } const EL_GROUP = getGroup(STATE, el); const GROUP = STATE.GROUPS[EL_GROUP]; // Check if element exists if (!GROUP) { return; } const EL_INDEX = GROUP.triggerElements.indexOf(el); if (EL_INDEX === -1) { return; } const IS_CURRENT_EL = isOpen() && EL_GROUP === STATE.activeGroup && EL_INDEX === STATE.currentIndex; // Remove group data if (GROUP.contentElements[EL_INDEX]) { const content = GROUP.contentElements[EL_INDEX]; if (content.tagName === 'IMG') { content.src = ''; content.srcset = ''; } } // Remove DOM element const sliderElement = GROUP.sliderElements[EL_INDEX]; if (sliderElement && sliderElement.parentNode) { sliderElement.parentNode.removeChild(sliderElement); } // Remove all array elements GROUP.triggerElements.splice(EL_INDEX, 1); GROUP.sliderElements.splice(EL_INDEX, 1); GROUP.contentElements.splice(EL_INDEX, 1); if (STATE.config.zoomIndicator) { removeZoomIndicator(el); } if (isOpen() && EL_GROUP === STATE.activeGroup) { if (IS_CURRENT_EL) { if (GROUP.triggerElements.length === 0) { close(); } else if (STATE.currentIndex >= GROUP.triggerElements.length) { select(GROUP.triggerElements.length - 1); } else { updateAttributes(STATE); updateSliderNavigationStatus(STATE); updateCounter(STATE); } } else if (EL_INDEX < STATE.currentIndex) { STATE.currentIndex--; updateAttributes(STATE); updateSliderNavigationStatus(STATE); updateCounter(STATE); } else { updateAttributes(STATE); updateSliderNavigationStatus(STATE); updateCounter(STATE); } } // Unbind click event handler el.removeEventListener('click', triggerParvus); el.classList.remove('parvus-trigger'); }; /** * Open Parvus * * @param {HTMLElement} el */ const open = el => { if (!STATE.lightbox || !el || !el.classList.contains('parvus-trigger') || isOpen()) { return; } STATE.activeGroup = getGroup(STATE, el); const GROUP = STATE.GROUPS[STATE.activeGroup]; const EL_INDEX = GROUP.triggerElements.indexOf(el); if (EL_INDEX === -1) { throw new Error('Ups, element not found in group.'); } STATE.currentIndex = EL_INDEX; history.pushState({ parvus: 'close' }, 'Image', window.location.href); bindEvents(); if (STATE.config.hideScrollbar) { document.body.style.marginInlineEnd = `${getScrollbarWidth()}px`; document.body.style.overflow = 'hidden'; } STATE.lightbox.classList.add('parvus--is-opening'); STATE.lightbox.showModal(); createSlider(STATE); createSlide(STATE, STATE.currentIndex); updateOffset(STATE); updateAttributes(STATE); updateSliderNavigationStatus(STATE); updateCounter(STATE); loadSlide(STATE, STATE.currentIndex); createImage(STATE, el, STATE.currentIndex, () => { loadImage(STATE, STATE.currentIndex, true); STATE.lightbox.classList.remove('parvus--is-opening'); GROUP.slider.classList.add('parvus__slider--animate'); }); preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex + 1); preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex - 1); // Execute afterOpen hook PLUGIN_MANAGER.executeHook('afterOpen', { element: el, state: STATE }); // Create and dispatch a new event dispatchCustomEvent(STATE.lightbox, 'open'); }; /** * Close Parvus */ const close = () => { if (!isOpen()) { return; } const IMAGE = STATE.GROUPS[STATE.activeGroup].contentElements[STATE.currentIndex]; const THUMBNAIL = STATE.GROUPS[STATE.activeGroup].triggerElements[STATE.currentIndex]; unbindEvents(); STATE.clearDrag(); if (history.state?.parvus === 'close') { history.back(); } STATE.lightbox.classList.add('parvus--is-closing'); const transitionendHandler = () => { // Reset the image zoom (if ESC was pressed or went back in the browser history) // after the ViewTransition (otherwise it looks bad) if (STATE.isPinching) { resetZoom(STATE, IMAGE); } leaveSlide(STATE, STATE.currentIndex); STATE.lightbox.close(); STATE.lightbox.classList.remove('parvus--is-closing'); STATE.lightbox.classList.remove('parvus--is-vertical-closing'); STATE.GROUPS[STATE.activeGroup].slider.remove(); STATE.GROUPS[STATE.activeGroup].slider = null; STATE.GROUPS[STATE.activeGroup].sliderElements = []; STATE.GROUPS[STATE.activeGroup].contentElements = []; STATE.counter.removeAttribute('aria-hidden'); STATE.previousButton.removeAttribute('aria-hidden'); STATE.previousButton.removeAttribute('aria-disabled'); STATE.nextButton.removeAttribute('aria-hidden'); STATE.nextButton.removeAttribute('aria-disabled'); if (STATE.config.hideScrollbar) { document.body.style.marginInlineEnd = ''; document.body.style.overflow = ''; } // Execute afterClose hook PLUGIN_MANAGER.executeHook('afterClose', { state: STATE }); }; if (IMAGE && IMAGE.tagName === 'IMG') { if (document.startViewTransition) { IMAGE.style.viewTransitionName = 'lightboximage'; const transition = document.startViewTransition(() => { IMAGE.style.opacity = '0'; IMAGE.style.viewTransitionName = null; THUMBNAIL.style.viewTransitionName = 'lightboximage'; }); transition.finished.finally(() => { transitionendHandler(); THUMBNAIL.style.viewTransitionName = null; }); } else { IMAGE.style.opacity = '0'; requestAnimationFrame(transitionendHandler); } } else { transitionendHandler(); } }; /** * Select a specific slide by index * * @param {number} index - Index of the slide to select */ const select = index => { if (!isOpen()) { throw new Error("Oops, I'm closed."); } if (typeof index !== 'number' || isNaN(index)) { throw new Error('Oops, no slide specified.'); } const GROUP = STATE.GROUPS[STATE.activeGroup]; const triggerElements = GROUP.triggerElements; if (index === STATE.currentIndex) { throw new Error(`Oops, slide ${index} is already selected.`); } if (index < 0 || index >= triggerElements.length) { throw new Error(`Oops, I can't find slide ${index}.`); } const OLD_INDEX = STATE.currentIndex; STATE.currentIndex = index; if (GROUP.sliderElements[index]) { loadSlide(STATE, index); } else { createSlide(STATE, index); createImage(STATE, GROUP.triggerElements[index], index, () => { loadImage(STATE, index); }); loadSlide(STATE, index); } updateOffset(STATE); updateSliderNavigationStatus(STATE); updateCounter(STATE); // Execute slideChange hook PLUGIN_MANAGER.executeHook('slideChange', { index, oldIndex: OLD_INDEX, state: STATE }); if (index < OLD_INDEX) { preload(STATE, createSlide, createImage, loadImage, index - 1); } else { preload(STATE, createSlide, createImage, loadImage, index + 1); } leaveSlide(STATE, OLD_INDEX); // Create and dispatch a new event dispatchCustomEvent(STATE.lightbox, 'select'); }; /** * Select the previous slide */ const previous = () => { if (STATE.currentIndex > 0) { select(STATE.currentIndex - 1); } }; /** * Select the next slide */ const next = () => { const { triggerElements } = STATE.GROUPS[STATE.activeGroup]; if (STATE.currentIndex < triggerElements.length - 1) { select(STATE.currentIndex + 1); } }; /** * Bind specified events */ const bindEvents = () => { const actions = { close, previous, next, updateOffset: () => updateOffset(STATE) }; // Create handlers with state and actions keydownHandler = createKeydownHandler(STATE, actions); clickHandler = createClickHandler(STATE, actions); resizeHandler = createResizeHandler(STATE, () => updateOffset(STATE)); const updateAfterDragHandler = () => updateAfterDrag(STATE, actions); const pinchZoomHandler = img => pinchZoom(STATE, img); const doSwipeHandler = () => doSwipe(STATE); const resetZoomHandler = img => resetZoom(STATE, img); pointerdownHandler = createPointerdownHandler(STATE); pointermoveHandler = createPointermoveHandler(STATE, pinchZoomHandler, doSwipeHandler); pointerupHandler = createPointerupHandler(STATE, resetZoomHandler, updateAfterDragHandler); BROWSER_WINDOW.addEventListener('keydown', keydownHandler); BROWSER_WINDOW.addEventListener('resize', resizeHandler); // Popstate event BROWSER_WINDOW.addEventListener('popstate', close); // Check for any OS level changes to the prefers reduced motion preference MOTIONQUERY.addEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY)); // Click event STATE.lightbox.addEventListener('click', clickHandler); // Pointer events STATE.lightbox.addEventListener('pointerdown', pointerdownHandler, { passive: false }); STATE.lightbox.addEventListener('pointerup', pointerupHandler, { passive: true }); STATE.lightbox.addEventListener('pointermove', pointermoveHandler, { passive: false }); }; /** * Unbind specified events */ const unbindEvents = () => { BROWSER_WINDOW.removeEventListener('keydown', keydownHandler); BROWSER_WINDOW.removeEventListener('resize', resizeHandler); // Popstate event BROWSER_WINDOW.removeEventListener('popstate', close); // Check for any OS level changes to the prefers reduced motion preference MOTIONQUERY.removeEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY)); // Click event STATE.lightbox.removeEventListener('click', clickHandler); // Pointer events STATE.lightbox.removeEventListener('pointerdown', pointerdownHandler); STATE.lightbox.removeEventListener('pointerup', pointerupHandler); STATE.lightbox.removeEventListener('pointermove', pointermoveHandler); }; /** * Destroy Parvus */ const destroy = () => { if (!STATE.lightbox) { return; } if (isOpen()) { close(); } // Add setTimeout to ensure all possible close transitions are completed setTimeout(() => { unbindEvents(); // Remove all registered event listeners for custom events const eventTypes = ['open', 'close', 'select', 'destroy']; eventTypes.forEach(eventType => { const listeners = STATE.lightbox._listeners?.[eventType] || []; listeners.forEach(listener => { STATE.lightbox.removeEventListener(eventType, listener); }); }); // Remove event listeners from trigger elements const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll('.parvus-trigger'); LIGHTBOX_TRIGGER_ELS.forEach(el => { el.removeEventListener('click', triggerParvus); el.classList.remove('parvus-trigger'); if (STATE.config.zoomIndicator) { removeZoomIndicator(el); } if (el.dataset.group) { delete el.dataset.group; } }); // Create and dispatch a new event dispatchCustomEvent(STATE.lightbox, 'destroy'); STATE.lightbox.remove(); // Remove references STATE.lightbox = null; STATE.lightboxOverlay = null; STATE.toolbar = null; STATE.toolbarLeft = null; STATE.toolbarRight = null; STATE.controls = null; STATE.previousButton = null; STATE.nextButton = null; STATE.closeButton = null; STATE.counter = null; // Remove group data Object.keys(STATE.GROUPS).forEach(groupKey => { const group = STATE.GROUPS[groupKey]; if (group && group.contentElements) { group.contentElements.forEach(content => { if (content && content.tagName === 'IMG') { content.src = ''; content.srcset = ''; } }); } delete STATE.GROUPS[groupKey]; }); // Reset variables STATE.groupIdCounter = 0; STATE.newGroup = null; STATE.activeGroup = null; STATE.currentIndex = 0; }, 1000); }; /** * Check if Parvus is open * * @returns {boolean} - True if Parvus is open, otherwise false */ const isOpen = () => { return STATE.lightbox?.hasAttribute('open'); }; /** * Get the current index * * @returns {number} - The current index */ const getCurrentIndex = () => { return STATE.currentIndex; }; /** * Bind a specific event listener * * @param {String} eventName - The name of the event to bind * @param {Function} callback - The callback function */ const on$1 = (eventName, callback) => { on(STATE.lightbox, eventName, callback); }; /** * Unbind a specific event listener * * @param {String} eventName - The name of the event to unbind * @param {Function} callback - The callback function */ const off$1 = (eventName, callback) => { off(STATE.lightbox, eventName, callback); }; /** * Use a plugin * * @param {Object} plugin - Plugin object * @param {Object} options - Plugin options */ const use = (plugin, options = {}) => { PLUGIN_MANAGER.register(plugin, options); }; /** * Add a hook callback * * @param {String} hookName - Hook name * @param {Function} callback - Callback function */ const addHook = (hookName, callback) => { PLUGIN_MANAGER.addHook(hookName, callback); }; /** * Get registered plugins * * @returns {Array} Array of plugin names */ const getPlugins = () => { return PLUGIN_MANAGER.getPlugins(); }; /** * Init */ const init = () => { // Merge user options into defaults STATE.config = mergeOptions(userOptions); reducedMotionCheck(STATE, MOTIONQUERY); // Install plugins with context const pluginContext = { state: STATE, on: on, addHook: PLUGIN_MANAGER.addHook.bind(PLUGIN_MANAGER), config: STATE.config }; PLUGIN_MANAGER.install(pluginContext); if (STATE.config.gallerySelector !== null) { // Get a list of all `gallerySelector` elements within the document const GALLERY_ELS = document.querySelectorAll(STATE.config.gallerySelector); // Execute a few things once per element GALLERY_ELS.forEach((galleryEl, index) => { const GALLERY_INDEX = index; // Get a list of all `selector` elements within the `gallerySelector` const LIGHTBOX_TRIGGER_GALLERY_ELS = galleryEl.querySelectorAll(STATE.config.selector); // Execute a few things once per element LIGHTBOX_TRIGGER_GALLERY_ELS.forEach(lightboxTriggerEl => { lightboxTriggerEl.setAttribute('data-group', `parvus-gallery-${GALLERY_INDEX}`); add(lightboxTriggerEl); }); }); } // Get a list of all `selector` elements outside or without the `gallerySelector` const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(`${STATE.config.selector}:not(.parvus-trigger)`); LIGHTBOX_TRIGGER_ELS.forEach(add); }; init(); return { init, open, close, select, previous, next, currentIndex: getCurrentIndex, add, remove, destroy, isOpen, on: on$1, off: off$1, use, addHook, getPlugins }; } return Parvus; })); ================================================ FILE: package.json ================================================ { "name": "parvus", "type": "module", "version": "3.1.0", "description": "An open source, dependency free image lightbox with the goal of being accessible.", "main": "./dist/js/parvus.js", "module": "./dist/js/parvus.esm.js", "style": "./dist/css/parvus.css", "devDependencies": { "@babel/core": "^7.29.0", "@babel/preset-env": "^7.29.2", "@rollup/plugin-babel": "^7.0.0", "@rollup/plugin-commonjs": "^29.0.2", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^1.0.0", "core-js": "^3.49.0", "postcss": "^8.5.10", "rollup": "^4.60.2", "rollup-plugin-license": "^3.7.1", "rollup-plugin-postcss": "^4.0.2", "sass": "^1.99.0", "standard": "^17.1.2", "stylelint": "^17.8.0", "stylelint-config-standard-scss": "^17.0.0", "stylelint-scss": "^7.0.0", "stylelint-use-logical": "^2.1.3" }, "browserslist": [ "last 2 versions and > 1% and not dead" ], "standard": { "globals": [ "Image", "history", "CustomEvent", "requestAnimationFrame", "getComputedStyle" ] }, "scripts": { "build": "npm run testCss && npm run buildCss && npm run testJs && npm run buildJs", "buildCss": "rollup -c --environment BUILDCSS --bundleConfigAsCjs", "buildJs": "rollup -c --environment BUILDJS --bundleConfigAsCjs", "buildWatch": "npm run buildWatchJs && npm run buildWatchCss", "buildWatchCss": "rollup -c -w --environment BUILDCSS --bundleConfigAsCjs", "buildWatchJs": "rollup -c -w --environment BUILDJS --bundleConfigAsCjs", "testCss": "stylelint \"src/scss/parvus.scss\"", "testJs": "standard \"src/js/parvus.js\"", "test": "npm run testCss && npm run testJs" }, "exports": { ".": { "import": "./dist/js/parvus.esm.js", "require": "./dist/js/parvus.js" }, "./src/scss/*": "./src/scss/*.scss", "./src/l10n/*": "./src/l10n/*.js" }, "repository": { "type": "git", "url": "git://github.com/deoostfrees/parvus.git" }, "keywords": [ "lightbox", "accessible", "a11y", "javascript", "vanilla", "scss", "css" ], "author": "Benjamin de Oostfrees", "license": "MIT", "bugs": { "url": "https://github.com/deoostfrees/parvus/issues" }, "homepage": "https://github.com/deoostfrees/parvus" } ================================================ FILE: rollup.config.js ================================================ import resolve from '@rollup/plugin-node-resolve' import commonjs from '@rollup/plugin-commonjs' import terser from '@rollup/plugin-terser' import postcss from 'rollup-plugin-postcss' import babel from '@rollup/plugin-babel' import license from 'rollup-plugin-license' import pkg from './package.json' const bannerContent = ` Parvus @author ${pkg.author} @version ${pkg.version} @url ${pkg.homepage} ${pkg.license} license` const rollupBuilds = [] /** * Build JavaScript * */ if (process.env.BUILDJS) { rollupBuilds.push({ input: './src/js/parvus.js', output: [ { format: 'umd', file: './dist/js/parvus.js', name: 'Parvus' }, { format: 'es', file: './dist/js/parvus.esm.js', name: 'Parvus' }, { format: 'umd', file: './dist/js/parvus.min.js', name: 'Parvus', plugins: [ terser(), license({ banner: { content: bannerContent } }) ] }, { format: 'es', file: './dist/js/parvus.esm.min.js', name: 'Parvus', plugins: [ terser(), license({ banner: { content: bannerContent } }) ] } ], plugins: [ resolve({ browser: true }), commonjs(), babel({ babelHelpers: 'bundled', exclude: 'node_modules/**', presets: [ ['@babel/preset-env', { corejs: 3.15, useBuiltIns: 'entry' }] ] }), license({ banner: { content: bannerContent } }) ], watch: { clearScreen: false } }) } /** * Build CSS * */ if (process.env.BUILDCSS) { rollupBuilds.push( { input: './src/scss/parvus.scss', output: [ { file: './dist/css/parvus.css' } ], plugins: [ resolve({ browser: true }), commonjs(), postcss({ extract: true }), license({ banner: { content: bannerContent } }) ], watch: { clearScreen: false } }, { input: './src/scss/parvus.scss', output: [ { file: './dist/css/parvus.min.css' } ], plugins: [ resolve({ browser: true }), commonjs(), postcss({ extract: true, minimize: true }), license({ banner: { content: bannerContent } }) ], watch: { clearScreen: false } } ) } export default rollupBuilds ================================================ FILE: src/js/core/config.js ================================================ import en from '../../l10n/en.js' /** * Default configuration options */ export const DEFAULT_OPTIONS = { selector: '.lightbox', gallerySelector: null, zoomIndicator: true, captions: true, captionsSelector: 'self', captionsAttribute: 'data-caption', copyright: true, copyrightSelector: 'self', copyrightAttribute: 'data-copyright', docClose: true, swipeClose: true, simulateTouch: true, threshold: 50, hideScrollbar: true, lightboxIndicatorIcon: '', previousButtonIcon: '', nextButtonIcon: '', closeButtonIcon: '', l10n: en } /** * Merge default options with user-provided options * * @param {Object} userOptions - User-provided options * @returns {Object} - Merged options object */ export const mergeOptions = (userOptions) => { const MERGED_OPTIONS = { ...DEFAULT_OPTIONS, ...userOptions } if (userOptions && userOptions.l10n) { MERGED_OPTIONS.l10n = { ...DEFAULT_OPTIONS.l10n, ...userOptions.l10n } } return MERGED_OPTIONS } ================================================ FILE: src/js/core/events.js ================================================ /** * Event System Module * * Handles custom event dispatching and listeners */ /** * Dispatch a custom event * * @param {HTMLElement} lightbox - The lightbox element * @param {String} type - The type of the event to dispatch * @returns {void} */ export const dispatchCustomEvent = (lightbox, type) => { const CUSTOM_EVENT = new CustomEvent(type, { cancelable: true }) lightbox.dispatchEvent(CUSTOM_EVENT) } /** * Bind a specific event listener * * @param {HTMLElement} lightbox - The lightbox element * @param {String} eventName - The name of the event to bind * @param {Function} callback - The callback function * @returns {void} */ export const on = (lightbox, eventName, callback) => { if (lightbox) { lightbox.addEventListener(eventName, callback) } } /** * Unbind a specific event listener * * @param {HTMLElement} lightbox - The lightbox element * @param {String} eventName - The name of the event to unbind * @param {Function} callback - The callback function * @returns {void} */ export const off = (lightbox, eventName, callback) => { if (lightbox) { lightbox.removeEventListener(eventName, callback) } } ================================================ FILE: src/js/core/navigation.js ================================================ /** * Navigation Module * * Handles slide navigation and transitions */ /** * Update offset * * @param {Object} state - The application state * @returns {void} */ export const updateOffset = (state) => { state.activeGroup = state.activeGroup !== null ? state.activeGroup : state.newGroup state.offset = -state.currentIndex * state.lightbox.offsetWidth state.GROUPS[state.activeGroup].slider.style.transform = `translate3d(${state.offset}px, 0, 0)` state.offsetTmp = state.offset } /** * Load slide with the specified index * * @param {Object} state - The application state * @param {Number} index - The index of the slide to be loaded * @returns {void} */ export const loadSlide = (state, index) => { state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'false') } /** * Leave slide * * @param {Object} state - The application state * @param {Number} index - The index of the slide to leave * @returns {void} */ export const leaveSlide = (state, index) => { if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) { state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'true') } } /** * Preload slide with the specified index * * @param {Object} state - The application state * @param {Function} createSlide - Create slide function * @param {Function} createImage - Create image function * @param {Function} loadImage - Load image function * @param {Number} index - The index of the slide to be preloaded * @returns {void} */ export const preload = (state, createSlide, createImage, loadImage, index) => { if (index < 0 || index >= state.GROUPS[state.activeGroup].triggerElements.length || state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) { return } createSlide(state, index) createImage(state, state.GROUPS[state.activeGroup].triggerElements[index], index, () => { loadImage(state, index) }) } ================================================ FILE: src/js/core/plugins.js ================================================ /** * Plugin management for Parvus * * Provides a system for registering and managing plugins */ export class PluginManager { constructor () { this.plugins = [] this.hooks = {} this.context = null this.isInitialized = false } /** * Register a plugin * * @param {Object} plugin - Plugin object with name and install function * @param {Object} options - Plugin-specific options */ register (plugin, options = {}) { if (!plugin || typeof plugin.install !== 'function') { throw new Error('Plugin must have an install function') } if (!plugin.name) { throw new Error('Plugin must have a name') } // Check if plugin is already registered const existingPlugin = this.plugins.find(p => p.name === plugin.name) if (existingPlugin) { console.warn(`Plugin "${plugin.name}" is already registered`) return } this.plugins.push({ plugin, options }) // If already initialized, install immediately if (this.isInitialized && this.context) { this.installPlugin(plugin, options) } } /** * Install a single plugin * * @param {Object} plugin - Plugin object * @param {Object} options - Plugin options */ installPlugin (plugin, options) { try { plugin.install(this.context, options) // If lightbox already exists, execute afterInit hook for this plugin immediately if (this.context && this.context.state && this.context.state.lightbox) { this.executeHook('afterInit', { state: this.context.state }) } } catch (error) { console.error(`Failed to install plugin "${plugin.name}":`, error) } } /** * Install all registered plugins * * @param {Object} context - Parvus instance context */ install (context) { this.context = context this.isInitialized = true this.plugins.forEach(({ plugin, options }) => { this.installPlugin(plugin, options) }) } /** * Execute a hook * * @param {String} hookName - Name of the hook * @param {*} data - Data to pass to hook callbacks */ executeHook (hookName, data) { const callbacks = this.hooks[hookName] || [] callbacks.forEach(callback => { try { callback(data) } catch (error) { console.error(`Error in hook "${hookName}":`, error) } }) } /** * Register a hook callback * * @param {String} hookName - Name of the hook * @param {Function} callback - Callback function */ addHook (hookName, callback) { if (!this.hooks[hookName]) { this.hooks[hookName] = [] } this.hooks[hookName].push(callback) } /** * Remove a hook callback * * @param {String} hookName - Name of the hook * @param {Function} callback - Callback function to remove */ removeHook (hookName, callback) { if (!this.hooks[hookName]) return this.hooks[hookName] = this.hooks[hookName].filter(cb => cb !== callback) } /** * Get all registered plugins * * @returns {Array} Array of plugin names */ getPlugins () { return this.plugins.map(p => p.plugin.name) } } ================================================ FILE: src/js/core/state.js ================================================ /** * State management for Parvus * * Centralizes all mutable state variables */ export class ParvusState { constructor () { // Group management this.GROUP_ATTRIBUTES = { triggerElements: [], slider: null, sliderElements: [], contentElements: [] } this.GROUPS = {} this.groupIdCounter = 0 this.newGroup = null this.activeGroup = null this.currentIndex = 0 // Configuration this.config = {} // DOM elements this.lightbox = null this.lightboxOverlay = null this.lightboxOverlayOpacity = 1 this.toolbar = null this.toolbarLeft = null this.toolbarRight = null this.controls = null this.previousButton = null this.nextButton = null this.closeButton = null this.counter = null // Drag & interaction state this.drag = {} this.isDraggingX = false this.isDraggingY = false this.pointerDown = false this.activePointers = new Map() // Zoom state this.currentScale = 1 this.isPinching = false this.isTap = false this.pinchStartDistance = 0 this.lastPointersId = null // Offset & animation this.offset = null this.offsetTmp = null this.resizeTicking = false this.isReducedMotion = true } /** * Clear drag state */ clearDrag () { this.drag = { startX: 0, endX: 0, startY: 0, endY: 0 } } /** * Get the active group * * @returns {Object} The active group */ getActiveGroup () { return this.GROUPS[this.activeGroup] } /** * Reset zoom state */ resetZoomState () { this.isPinching = false this.isTap = false this.currentScale = 1 this.pinchStartDistance = 0 this.lastPointersId = '' } } ================================================ FILE: src/js/core/utils.js ================================================ /** * Utils Module * * Utility functions */ /** * Check prefers reduced motion * * @param {Object} state - The application state * @param {MediaQueryList} motionQuery - The media query list * @returns {void} */ export const reducedMotionCheck = (state, motionQuery) => { if (motionQuery.matches) { state.isReducedMotion = true } else { state.isReducedMotion = false } } /** * Retrieves or creates a group identifier for the given element * * @param {Object} state - The application state * @param {HTMLElement} el - DOM element to get or assign a group to * @returns {string} The group identifier associated with the element */ export const getGroup = (state, el) => { // Return existing group identifier if already assigned if (el.dataset.group) { return el.dataset.group } // Generate new unique group identifier using counter const EL_GROUP = `default-${state.groupIdCounter++}` // Assign the new group identifier to element's dataset el.dataset.group = EL_GROUP return EL_GROUP } ================================================ FILE: src/js/handlers/gestures.js ================================================ /** * Gesture Handler Module * * Handles gestures like pinch-to-zoom and swipe */ /** * Reset image zoom * * @param {Object} state - The application state * @param {HTMLImageElement} currentImg - The image * @returns {void} */ export const resetZoom = (state, currentImg) => { currentImg.style.transition = 'transform 0.3s ease' currentImg.style.transform = '' setTimeout(() => { currentImg.style.transition = '' currentImg.style.transformOrigin = '' }, 300) state.resetZoomState() state.lightbox.classList.remove('parvus--is-zooming') } /** * Pinch zoom gesture * * @param {Object} state - The application state * @param {HTMLImageElement} currentImg - The image to zoom * @returns {void} */ export const pinchZoom = (state, currentImg) => { // Determine current finger positions const POINTS = Array.from(state.activePointers.values()) // Calculate current distance between fingers const CURRENT_DISTANCE = Math.hypot( POINTS[1].clientX - POINTS[0].clientX, POINTS[1].clientY - POINTS[0].clientY ) // Calculate the midpoint between the two points const MIDPOINT_X = (POINTS[0].clientX + POINTS[1].clientX) / 2 const MIDPOINT_Y = (POINTS[0].clientY + POINTS[1].clientY) / 2 // Convert midpoint to relative position within the image const IMG_RECT = currentImg.getBoundingClientRect() const RELATIVE_X = (MIDPOINT_X - IMG_RECT.left) / IMG_RECT.width const RELATIVE_Y = (MIDPOINT_Y - IMG_RECT.top) / IMG_RECT.height // When pinch gesture is about to start or the finger IDs have changed // Use a unique ID based on the pointer IDs to recognize changes const CURRENT_POINTERS_ID = POINTS.map(p => p.pointerId).sort().join('-') const IS_NEW_POINTER_COMBINATION = state.lastPointersId !== CURRENT_POINTERS_ID if (!state.isPinching || IS_NEW_POINTER_COMBINATION) { state.isPinching = true state.lastPointersId = CURRENT_POINTERS_ID // Save the start distance and current scaling as a basis state.pinchStartDistance = CURRENT_DISTANCE / state.currentScale // Store initial pinch position for this gesture if ((!currentImg.style.transformOrigin && state.currentScale === 1) || (state.currentScale === 1 && IS_NEW_POINTER_COMBINATION)) { // Set the transform origin to the pinch midpoint currentImg.style.transformOrigin = `${RELATIVE_X * 100}% ${RELATIVE_Y * 100}%` } state.lightbox.classList.add('parvus--is-zooming') } // Calculate scaling factor based on distance change const SCALE_FACTOR = CURRENT_DISTANCE / state.pinchStartDistance // Limit scaling to 1 - 3 state.currentScale = Math.min(Math.max(1, SCALE_FACTOR), 3) currentImg.style.willChange = 'transform' currentImg.style.transform = `scale(${state.currentScale})` } /** * Determine the swipe direction (horizontal or vertical) * * @param {Object} state - The application state * @returns {void} */ export const doSwipe = (state) => { const MOVEMENT_THRESHOLD = 1.5 const MAX_OPACITY_DISTANCE = 100 const DIRECTION_BIAS = 1.15 const { startX, endX, startY, endY } = state.drag const MOVEMENT_X = startX - endX const MOVEMENT_Y = endY - startY const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X) const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y) const GROUP = state.GROUPS[state.activeGroup] const SLIDER = GROUP.slider const TOTAL_SLIDES = GROUP.triggerElements.length const handleHorizontalSwipe = (movementX, distance) => { const IS_FIRST_SLIDE = state.currentIndex === 0 const IS_LAST_SLIDE = state.currentIndex === TOTAL_SLIDES - 1 const IS_LEFT_SWIPE = movementX > 0 const IS_RIGHT_SWIPE = movementX < 0 if ((IS_FIRST_SLIDE && IS_RIGHT_SWIPE) || (IS_LAST_SLIDE && IS_LEFT_SWIPE)) { const DAMPING_FACTOR = 1 / (1 + Math.pow(distance / 100, 0.15)) const REDUCED_MOVEMENT = movementX * DAMPING_FACTOR SLIDER.style.transform = ` translate3d(${state.offsetTmp - Math.round(REDUCED_MOVEMENT)}px, 0, 0) ` } else { SLIDER.style.transform = ` translate3d(${state.offsetTmp - Math.round(movementX)}px, 0, 0) ` } } const handleVerticalSwipe = (movementY, distance) => { if (!state.isReducedMotion && distance <= 100) { const NEW_OVERLAY_OPACITY = Math.max(0, state.lightboxOverlayOpacity - (distance / MAX_OPACITY_DISTANCE)) state.lightboxOverlay.style.opacity = NEW_OVERLAY_OPACITY } state.lightbox.classList.add('parvus--is-vertical-closing') SLIDER.style.transform = ` translate3d(${state.offsetTmp}px, ${Math.round(movementY)}px, 0) ` } if (state.isDraggingX || state.isDraggingY) { if (state.isDraggingX) { handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE) } else if (state.isDraggingY) { handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE) } return } // Direction detection based on the relative ratio of movements if (MOVEMENT_X_DISTANCE > MOVEMENT_THRESHOLD || MOVEMENT_Y_DISTANCE > MOVEMENT_THRESHOLD) { // Horizontal swipe if X-movement is stronger than Y-movement * DIRECTION_BIAS if (MOVEMENT_X_DISTANCE > MOVEMENT_Y_DISTANCE * DIRECTION_BIAS && TOTAL_SLIDES > 1) { state.isDraggingX = true state.isDraggingY = false handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE) } else if (MOVEMENT_Y_DISTANCE > MOVEMENT_X_DISTANCE * DIRECTION_BIAS && state.config.swipeClose) { // Vertical swipe if Y-movement is stronger than X-movement * DIRECTION_BIAS state.isDraggingX = false state.isDraggingY = true handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE) } } } /** * Recalculate drag/swipe event after pointerup * * @param {Object} state - The application state * @param {Object} actions - Navigation actions * @returns {void} */ export const updateAfterDrag = (state, actions) => { const { startX, startY, endX, endY } = state.drag const MOVEMENT_X = endX - startX const MOVEMENT_Y = endY - startY const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X) const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y) const { triggerElements } = state.GROUPS[state.activeGroup] const TOTAL_TRIGGER_ELEMENTS = triggerElements.length if (state.isDraggingX) { const IS_RIGHT_SWIPE = MOVEMENT_X > 0 if (MOVEMENT_X_DISTANCE >= state.config.threshold) { if (IS_RIGHT_SWIPE && state.currentIndex > 0) { actions.previous() } else if (!IS_RIGHT_SWIPE && state.currentIndex < TOTAL_TRIGGER_ELEMENTS - 1) { actions.next() } } actions.updateOffset() } else if (state.isDraggingY) { if (MOVEMENT_Y_DISTANCE >= state.config.threshold && state.config.swipeClose) { actions.close() } else { state.lightbox.classList.remove('parvus--is-vertical-closing') actions.updateOffset() } state.lightboxOverlay.style.opacity = '' } else { actions.updateOffset() } } ================================================ FILE: src/js/handlers/images.js ================================================ /** * Image Handler Module * * Handles image loading, captions, and dimensions */ /** * Add caption to the container element * * @param {Object} config - Configuration object * @param {HTMLElement} containerEl - The container element to which the caption will be added * @param {HTMLElement} imageEl - The image the caption is linked to * @param {HTMLElement} el - The trigger element associated with the caption * @param {Number} index - The index of the caption * @returns {void} */ export const addCaption = (config, containerEl, imageEl, el, index) => { const getCaptionData = (triggerEl) => { const { captionsAttribute, captionsSelector, captionsIdAttribute = 'data-caption-id' } = config // Check for an ID reference on the trigger element // This allows the caption to be anywhere on the page const CAPTION_ID = triggerEl.getAttribute(captionsIdAttribute) if (CAPTION_ID) { const CAPTION_EL = document.getElementById(CAPTION_ID) if (CAPTION_EL) { return CAPTION_EL.innerHTML } } // Check for a direct caption attribute on the trigger element const DIRECT_CAPTION = triggerEl.getAttribute(captionsAttribute) if (DIRECT_CAPTION) { return DIRECT_CAPTION } // Query for a selector inside the trigger element if (captionsSelector !== 'self') { const CAPTION_EL = triggerEl.querySelector(captionsSelector) if (CAPTION_EL) { // Prefer a direct attribute on the found element, otherwise use its content return CAPTION_EL.getAttribute(captionsAttribute) || CAPTION_EL.innerHTML } } return null } const CAPTION_DATA = getCaptionData(el) if (CAPTION_DATA) { const CAPTION_CONTAINER = document.createElement('div') const CAPTION_ID = `parvus__caption-${index}` CAPTION_CONTAINER.className = 'parvus__caption' CAPTION_CONTAINER.id = CAPTION_ID CAPTION_CONTAINER.innerHTML = `

${CAPTION_DATA}

` containerEl.appendChild(CAPTION_CONTAINER) imageEl.setAttribute('aria-describedby', CAPTION_ID) } } /** * Add copyright to the image container element * * @param {Object} config - Configuration object * @param {HTMLElement} imageContainer - The image container element (parvus__content) to which the copyright will be added * @param {HTMLElement} imageEl - The image the copyright is linked to * @param {HTMLElement} el - The trigger element associated with the copyright * @param {Number} index - The index of the copyright * @returns {void} */ export const addCopyright = (config, imageContainer, imageEl, el, index) => { const getCopyrightData = (triggerEl) => { const { copyrightAttribute, copyrightSelector, copyrightIdAttribute = 'data-copyright-id' } = config // Check for an ID reference on the trigger element // This allows the copyright to be anywhere on the page const COPYRIGHT_ID = triggerEl.getAttribute(copyrightIdAttribute) if (COPYRIGHT_ID) { const COPYRIGHT_EL = document.getElementById(COPYRIGHT_ID) if (COPYRIGHT_EL) { return COPYRIGHT_EL.innerHTML } } // Check for a direct copyright attribute on the trigger element const DIRECT_COPYRIGHT = triggerEl.getAttribute(copyrightAttribute) if (DIRECT_COPYRIGHT) { return DIRECT_COPYRIGHT } // Query for a selector inside the trigger element if (copyrightSelector !== 'self') { const COPYRIGHT_EL = triggerEl.querySelector(copyrightSelector) if (COPYRIGHT_EL) { // Prefer a direct attribute on the found element, otherwise use its content return COPYRIGHT_EL.getAttribute(copyrightAttribute) || COPYRIGHT_EL.innerHTML } } return null } const COPYRIGHT_DATA = getCopyrightData(el) if (COPYRIGHT_DATA) { const COPYRIGHT_CONTAINER = document.createElement('div') const COPYRIGHT_ID = `parvus__copyright-${index}` COPYRIGHT_CONTAINER.className = 'parvus__copyright' COPYRIGHT_CONTAINER.id = COPYRIGHT_ID COPYRIGHT_CONTAINER.innerHTML = `${COPYRIGHT_DATA}` imageContainer.appendChild(COPYRIGHT_CONTAINER) // If image already has aria-describedby (from caption), append copyright ID const existingAriaDescribedby = imageEl.getAttribute('aria-describedby') if (existingAriaDescribedby) { imageEl.setAttribute('aria-describedby', `${existingAriaDescribedby} ${COPYRIGHT_ID}`) } else { imageEl.setAttribute('aria-describedby', COPYRIGHT_ID) } } } /** * Create image * * @param {Object} state - The application state * @param {HTMLElement} el - The trigger element * @param {Number} index - The index * @param {Function} callback - Callback function * @returns {void} */ export const createImage = (state, el, index, callback) => { const { contentElements, sliderElements } = state.GROUPS[state.activeGroup] if (contentElements[index] !== undefined) { if (callback && typeof callback === 'function') { callback() } return } const CONTENT_CONTAINER_EL = sliderElements[index].querySelector('div') const IMAGE = new Image() const IMAGE_CONTAINER = document.createElement('div') const THUMBNAIL = el.querySelector('img') const LOADING_INDICATOR = document.createElement('div') IMAGE_CONTAINER.className = 'parvus__content' // Create loading indicator LOADING_INDICATOR.className = 'parvus__loader' LOADING_INDICATOR.setAttribute('role', 'progressbar') LOADING_INDICATOR.setAttribute('aria-label', state.config.l10n.lightboxLoadingIndicatorLabel) // Add loading indicator to content container CONTENT_CONTAINER_EL.appendChild(LOADING_INDICATOR) const checkImagePromise = new Promise((resolve, reject) => { IMAGE.onload = () => resolve(IMAGE) IMAGE.onerror = (error) => reject(error) }) checkImagePromise .then((loadedImage) => { loadedImage.style.opacity = 0 IMAGE_CONTAINER.appendChild(loadedImage) // Add copyright if available (inside IMAGE_CONTAINER) if (state.config.copyright) { addCopyright(state.config, IMAGE_CONTAINER, IMAGE, el, index) } CONTENT_CONTAINER_EL.appendChild(IMAGE_CONTAINER) // Add caption if available if (state.config.captions) { addCaption(state.config, CONTENT_CONTAINER_EL, IMAGE, el, index) } contentElements[index] = loadedImage // Set image width and height loadedImage.setAttribute('width', loadedImage.naturalWidth) loadedImage.setAttribute('height', loadedImage.naturalHeight) // Set image dimension setImageDimension(sliderElements[index], loadedImage) }) .catch(() => { const ERROR_CONTAINER = document.createElement('div') ERROR_CONTAINER.classList.add('parvus__content') ERROR_CONTAINER.classList.add('parvus__content--error') ERROR_CONTAINER.textContent = state.config.l10n.lightboxLoadingError CONTENT_CONTAINER_EL.appendChild(ERROR_CONTAINER) contentElements[index] = ERROR_CONTAINER }) .finally(() => { CONTENT_CONTAINER_EL.removeChild(LOADING_INDICATOR) if (callback && typeof callback === 'function') { callback() } }) // Add `sizes` attribute if (el.hasAttribute('data-sizes') && el.getAttribute('data-sizes') !== '') { IMAGE.setAttribute('sizes', el.getAttribute('data-sizes')) } // Add `srcset` attribute if (el.hasAttribute('data-srcset') && el.getAttribute('data-srcset') !== '') { IMAGE.setAttribute('srcset', el.getAttribute('data-srcset')) } // Add `src` attribute if (el.tagName === 'A') { IMAGE.setAttribute('src', el.href) } else { IMAGE.setAttribute('src', el.getAttribute('data-target')) } // `alt` attribute if (THUMBNAIL && THUMBNAIL.hasAttribute('alt') && THUMBNAIL.getAttribute('alt') !== '') { IMAGE.alt = THUMBNAIL.alt } else if (el.hasAttribute('data-alt') && el.getAttribute('data-alt') !== '') { IMAGE.alt = el.getAttribute('data-alt') } else { IMAGE.alt = '' } } /** * Load Image * * @param {Object} state - The application state * @param {Number} index - The index of the image to load * @param {Boolean} animate - Whether to animate the image * @returns {void} */ export const loadImage = (state, index, animate) => { const IMAGE = state.GROUPS[state.activeGroup].contentElements[index] if (IMAGE && IMAGE.tagName === 'IMG') { const THUMBNAIL = state.GROUPS[state.activeGroup].triggerElements[index] if (animate && document.startViewTransition) { THUMBNAIL.style.viewTransitionName = 'lightboximage' const transition = document.startViewTransition(() => { IMAGE.style.opacity = '' THUMBNAIL.style.viewTransitionName = null IMAGE.style.viewTransitionName = 'lightboximage' }) transition.finished.finally(() => { IMAGE.style.viewTransitionName = null }) } else { IMAGE.style.opacity = '' } } else { IMAGE.style.opacity = '' } } /** * Set image dimension * * @param {HTMLElement} slideEl - The slide element * @param {HTMLElement} contentEl - The content element * @returns {void} */ export const setImageDimension = (slideEl, contentEl) => { if (contentEl.tagName !== 'IMG') { return } const SRC_HEIGHT = contentEl.getAttribute('height') const SRC_WIDTH = contentEl.getAttribute('width') if (!SRC_HEIGHT || !SRC_WIDTH) { return } const SLIDE_EL_STYLES = getComputedStyle(slideEl) const HORIZONTAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingLeft) + parseFloat(SLIDE_EL_STYLES.paddingRight) const VERTICAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingTop) + parseFloat(SLIDE_EL_STYLES.paddingBottom) const CAPTION_EL = slideEl.querySelector('.parvus__caption') const CAPTION_HEIGHT = CAPTION_EL ? CAPTION_EL.getBoundingClientRect().height : 0 const MAX_WIDTH = slideEl.offsetWidth - HORIZONTAL_PADDING const MAX_HEIGHT = slideEl.offsetHeight - VERTICAL_PADDING - CAPTION_HEIGHT const RATIO = Math.min(MAX_WIDTH / SRC_WIDTH || 0, MAX_HEIGHT / SRC_HEIGHT || 0) const NEW_WIDTH = SRC_WIDTH * RATIO const NEW_HEIGHT = SRC_HEIGHT * RATIO const USE_ORIGINAL_SIZE = (SRC_WIDTH <= MAX_WIDTH && SRC_HEIGHT <= MAX_HEIGHT) contentEl.style.width = USE_ORIGINAL_SIZE ? '' : `${NEW_WIDTH}px` contentEl.style.height = USE_ORIGINAL_SIZE ? '' : `${NEW_HEIGHT}px` } /** * Create resize handler * * @param {Object} state - The application state * @param {Function} updateOffset - Update offset function * @returns {Function} Resize event handler */ export const createResizeHandler = (state, updateOffset) => { return () => { if (!state.resizeTicking) { state.resizeTicking = true window.requestAnimationFrame(() => { state.GROUPS[state.activeGroup].sliderElements.forEach((slide, index) => { setImageDimension(slide, state.GROUPS[state.activeGroup].contentElements[index]) }) updateOffset() state.resizeTicking = false }) } } } ================================================ FILE: src/js/handlers/keyboard.js ================================================ /** * Keyboard Event Handler Module * * Handles all keyboard interactions */ import { getFocusableChildren } from '../helpers/dom.js' /** * Create keyboard event handler * * @param {Object} state - The application state * @param {Object} actions - Actions object with navigation functions * @returns {Function} Keyboard event handler */ export const createKeydownHandler = (state, actions) => { return (event) => { const FOCUSABLE_CHILDREN = getFocusableChildren(state.lightbox) const FOCUSED_ITEM_INDEX = FOCUSABLE_CHILDREN.indexOf(document.activeElement) const lastIndex = FOCUSABLE_CHILDREN.length - 1 switch (event.code) { case 'Tab': { // Use the TAB key to navigate backwards and forwards if (event.shiftKey) { // Navigate backwards if (FOCUSED_ITEM_INDEX === 0) { FOCUSABLE_CHILDREN[lastIndex].focus() event.preventDefault() } } else { // Navigate forwards if (FOCUSED_ITEM_INDEX === lastIndex) { FOCUSABLE_CHILDREN[0].focus() event.preventDefault() } } break } case 'Escape': { // Close Parvus when the ESC key is pressed actions.close() event.preventDefault() break } case 'ArrowLeft': { // Show the previous slide when the PREV key is pressed actions.previous() event.preventDefault() break } case 'ArrowRight': { // Show the next slide when the NEXT key is pressed actions.next() event.preventDefault() break } } } } ================================================ FILE: src/js/handlers/pointer.js ================================================ /** * Pointer Event Handler Module * * Handles all pointer interactions (mouse, touch, pen) */ /** * Create pointerdown event handler * * @param {Object} state - The application state * @returns {Function} Pointerdown event handler */ export const createPointerdownHandler = (state) => { return (event) => { event.preventDefault() event.stopPropagation() state.isDraggingX = false state.isDraggingY = false state.pointerDown = true state.activePointers.set(event.pointerId, event) state.drag.startX = event.pageX state.drag.startY = event.pageY state.drag.endX = event.pageX state.drag.endY = event.pageY const { slider } = state.GROUPS[state.activeGroup] slider.classList.add('parvus__slider--is-dragging') slider.style.willChange = 'transform' state.isTap = state.activePointers.size === 1 if (state.config.swipeClose) { state.lightboxOverlayOpacity = getComputedStyle(state.lightboxOverlay).opacity } } } /** * Create pointermove event handler * * @param {Object} state - The application state * @param {Function} pinchZoom - Pinch zoom function * @param {Function} doSwipe - Swipe function * @returns {Function} Pointermove event handler */ export const createPointermoveHandler = (state, pinchZoom, doSwipe) => { return (event) => { event.preventDefault() if (!state.pointerDown) { return } const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex] // Update pointer position state.activePointers.set(event.pointerId, event) // Zoom if (CURRENT_IMAGE && CURRENT_IMAGE.tagName === 'IMG') { if (state.activePointers.size === 2) { pinchZoom(CURRENT_IMAGE) return } if (state.currentScale > 1) { return } } state.drag.endX = event.pageX state.drag.endY = event.pageY doSwipe() } } /** * Create pointerup event handler * * @param {Object} state - The application state * @param {Function} resetZoom - Reset zoom function * @param {Function} updateAfterDrag - Update after drag function * @returns {Function} Pointerup event handler */ export const createPointerupHandler = (state, resetZoom, updateAfterDrag) => { return (event) => { event.stopPropagation() const { slider } = state.GROUPS[state.activeGroup] state.activePointers.delete(event.pointerId) if (state.activePointers.size > 0) { return } state.pointerDown = false const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex] // Reset zoom state by one tap const MOVEMENT_X = Math.abs(state.drag.endX - state.drag.startX) const MOVEMENT_Y = Math.abs(state.drag.endY - state.drag.startY) const IS_TAP = MOVEMENT_X < 8 && MOVEMENT_Y < 8 && !state.isDraggingX && !state.isDraggingY && state.isTap slider.classList.remove('parvus__slider--is-dragging') slider.style.willChange = '' if (state.currentScale > 1) { if (IS_TAP) { resetZoom(CURRENT_IMAGE) } else { CURRENT_IMAGE.style.transform = ` scale(${state.currentScale}) ` } } else { if (state.isPinching) { resetZoom(CURRENT_IMAGE) } if (state.drag.endX || state.drag.endY) { updateAfterDrag() } } state.clearDrag() } } /** * Create click event handler * * @param {Object} state - The application state * @param {Object} actions - Actions object with navigation functions * @returns {Function} Click event handler */ export const createClickHandler = (state, actions) => { return (event) => { const { target } = event if (target === state.previousButton) { actions.previous() } else if (target === state.nextButton) { actions.next() } else if (target === state.closeButton || (state.config.docClose && !state.isDraggingY && !state.isDraggingX && target.classList.contains('parvus__slide'))) { actions.close() } event.stopPropagation() } } ================================================ FILE: src/js/helpers/dom.js ================================================ const BROWSER_WINDOW = window /** * Get scrollbar width * * @return {Number} - The scrollbar width */ export const getScrollbarWidth = () => { return BROWSER_WINDOW.innerWidth - document.documentElement.clientWidth } const FOCUSABLE_ELEMENTS = [ 'a:not([inert]):not([tabindex^="-"])', 'button:not([inert]):not([tabindex^="-"]):not(:disabled)', '[tabindex]:not([inert]):not([tabindex^="-"])' ] /** * Get the focusable children of the given element * * @return {Array} - An array of focusable children */ export const getFocusableChildren = (targetEl) => { return Array.from(targetEl.querySelectorAll(FOCUSABLE_ELEMENTS.join(', '))) .filter((child) => child.offsetParent !== null) } ================================================ FILE: src/js/parvus.js ================================================ // Helper modules import { getScrollbarWidth } from './helpers/dom.js' // Core modules import { mergeOptions } from './core/config.js' import { ParvusState } from './core/state.js' import { dispatchCustomEvent, on as addEventListener, off as removeEventListener } from './core/events.js' import { updateOffset, loadSlide, leaveSlide, preload } from './core/navigation.js' import { reducedMotionCheck, getGroup } from './core/utils.js' import { PluginManager } from './core/plugins.js' // UI modules import { createLightbox, createSlider, createSlide, updateCounter, updateAttributes, updateSliderNavigationStatus } from './ui/lightbox.js' import { addZoomIndicator, removeZoomIndicator } from './ui/zoom-indicator.js' // Handler modules import { createKeydownHandler } from './handlers/keyboard.js' import { createPointerdownHandler, createPointermoveHandler, createPointerupHandler, createClickHandler } from './handlers/pointer.js' import { resetZoom, pinchZoom, doSwipe, updateAfterDrag } from './handlers/gestures.js' import { createImage, loadImage, createResizeHandler } from './handlers/images.js' /** * Parvus Lightbox * * @param {Object} userOptions - User configuration options * @returns {Object} Parvus instance */ export default function Parvus (userOptions) { const BROWSER_WINDOW = window const STATE = new ParvusState() const MOTIONQUERY = BROWSER_WINDOW.matchMedia('(prefers-reduced-motion)') const PLUGIN_MANAGER = new PluginManager() // Event handlers will be created after actions are defined let keydownHandler, clickHandler, pointerdownHandler, pointermoveHandler, pointerupHandler, resizeHandler /** * Click event handler to trigger Parvus * * @param {Event} event - The click event object */ const triggerParvus = function triggerParvus (event) { event.preventDefault() open(this) } /** * Add an element * * @param {HTMLElement} el - The element to be added */ const add = (el) => { // Check element type and attributes const IS_VALID_LINK = el.tagName === 'A' && el.hasAttribute('href') const IS_VALID_BUTTON = el.tagName === 'BUTTON' && el.hasAttribute('data-target') if (!IS_VALID_LINK && !IS_VALID_BUTTON) { 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.') } // Check if the lightbox already exists if (!STATE.lightbox) { createLightbox(STATE) // Execute afterInit hook when lightbox is first created PLUGIN_MANAGER.executeHook('afterInit', { state: STATE }) } STATE.newGroup = getGroup(STATE, el) if (!STATE.GROUPS[STATE.newGroup]) { STATE.GROUPS[STATE.newGroup] = structuredClone(STATE.GROUP_ATTRIBUTES) } if (STATE.GROUPS[STATE.newGroup].triggerElements.includes(el)) { throw new Error('Ups, element already added.') } STATE.GROUPS[STATE.newGroup].triggerElements.push(el) if (STATE.config.zoomIndicator) { addZoomIndicator(el, STATE.config) } el.classList.add('parvus-trigger') el.addEventListener('click', triggerParvus) if (isOpen() && STATE.newGroup === STATE.activeGroup) { const EL_INDEX = STATE.GROUPS[STATE.newGroup].triggerElements.indexOf(el) createSlide(STATE, EL_INDEX) createImage(STATE, el, EL_INDEX, () => { loadImage(STATE, EL_INDEX) }) updateAttributes(STATE) updateSliderNavigationStatus(STATE) updateCounter(STATE) } } /** * Remove an element * * @param {HTMLElement} el - The element to be removed */ const remove = (el) => { if (!el || !el.hasAttribute('data-group')) { return } const EL_GROUP = getGroup(STATE, el) const GROUP = STATE.GROUPS[EL_GROUP] // Check if element exists if (!GROUP) { return } const EL_INDEX = GROUP.triggerElements.indexOf(el) if (EL_INDEX === -1) { return } const IS_CURRENT_EL = isOpen() && EL_GROUP === STATE.activeGroup && EL_INDEX === STATE.currentIndex // Remove group data if (GROUP.contentElements[EL_INDEX]) { const content = GROUP.contentElements[EL_INDEX] if (content.tagName === 'IMG') { content.src = '' content.srcset = '' } } // Remove DOM element const sliderElement = GROUP.sliderElements[EL_INDEX] if (sliderElement && sliderElement.parentNode) { sliderElement.parentNode.removeChild(sliderElement) } // Remove all array elements GROUP.triggerElements.splice(EL_INDEX, 1) GROUP.sliderElements.splice(EL_INDEX, 1) GROUP.contentElements.splice(EL_INDEX, 1) if (STATE.config.zoomIndicator) { removeZoomIndicator(el) } if (isOpen() && EL_GROUP === STATE.activeGroup) { if (IS_CURRENT_EL) { if (GROUP.triggerElements.length === 0) { close() } else if (STATE.currentIndex >= GROUP.triggerElements.length) { select(GROUP.triggerElements.length - 1) } else { updateAttributes(STATE) updateSliderNavigationStatus(STATE) updateCounter(STATE) } } else if (EL_INDEX < STATE.currentIndex) { STATE.currentIndex-- updateAttributes(STATE) updateSliderNavigationStatus(STATE) updateCounter(STATE) } else { updateAttributes(STATE) updateSliderNavigationStatus(STATE) updateCounter(STATE) } } // Unbind click event handler el.removeEventListener('click', triggerParvus) el.classList.remove('parvus-trigger') } /** * Open Parvus * * @param {HTMLElement} el */ const open = (el) => { if (!STATE.lightbox || !el || !el.classList.contains('parvus-trigger') || isOpen()) { return } STATE.activeGroup = getGroup(STATE, el) const GROUP = STATE.GROUPS[STATE.activeGroup] const EL_INDEX = GROUP.triggerElements.indexOf(el) if (EL_INDEX === -1) { throw new Error('Ups, element not found in group.') } STATE.currentIndex = EL_INDEX history.pushState({ parvus: 'close' }, 'Image', window.location.href) bindEvents() if (STATE.config.hideScrollbar) { document.body.style.marginInlineEnd = `${getScrollbarWidth()}px` document.body.style.overflow = 'hidden' } STATE.lightbox.classList.add('parvus--is-opening') STATE.lightbox.showModal() createSlider(STATE) createSlide(STATE, STATE.currentIndex) updateOffset(STATE) updateAttributes(STATE) updateSliderNavigationStatus(STATE) updateCounter(STATE) loadSlide(STATE, STATE.currentIndex) createImage(STATE, el, STATE.currentIndex, () => { loadImage(STATE, STATE.currentIndex, true) STATE.lightbox.classList.remove('parvus--is-opening') GROUP.slider.classList.add('parvus__slider--animate') }) preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex + 1) preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex - 1) // Execute afterOpen hook PLUGIN_MANAGER.executeHook('afterOpen', { element: el, state: STATE }) // Create and dispatch a new event dispatchCustomEvent(STATE.lightbox, 'open') } /** * Close Parvus */ const close = () => { if (!isOpen()) { return } const IMAGE = STATE.GROUPS[STATE.activeGroup].contentElements[STATE.currentIndex] const THUMBNAIL = STATE.GROUPS[STATE.activeGroup].triggerElements[STATE.currentIndex] unbindEvents() STATE.clearDrag() if (history.state?.parvus === 'close') { history.back() } STATE.lightbox.classList.add('parvus--is-closing') const transitionendHandler = () => { // Reset the image zoom (if ESC was pressed or went back in the browser history) // after the ViewTransition (otherwise it looks bad) if (STATE.isPinching) { resetZoom(STATE, IMAGE) } leaveSlide(STATE, STATE.currentIndex) STATE.lightbox.close() STATE.lightbox.classList.remove('parvus--is-closing') STATE.lightbox.classList.remove('parvus--is-vertical-closing') STATE.GROUPS[STATE.activeGroup].slider.remove() STATE.GROUPS[STATE.activeGroup].slider = null STATE.GROUPS[STATE.activeGroup].sliderElements = [] STATE.GROUPS[STATE.activeGroup].contentElements = [] STATE.counter.removeAttribute('aria-hidden') STATE.previousButton.removeAttribute('aria-hidden') STATE.previousButton.removeAttribute('aria-disabled') STATE.nextButton.removeAttribute('aria-hidden') STATE.nextButton.removeAttribute('aria-disabled') if (STATE.config.hideScrollbar) { document.body.style.marginInlineEnd = '' document.body.style.overflow = '' } // Execute afterClose hook PLUGIN_MANAGER.executeHook('afterClose', { state: STATE }) } if (IMAGE && IMAGE.tagName === 'IMG') { if (document.startViewTransition) { IMAGE.style.viewTransitionName = 'lightboximage' const transition = document.startViewTransition(() => { IMAGE.style.opacity = '0' IMAGE.style.viewTransitionName = null THUMBNAIL.style.viewTransitionName = 'lightboximage' }) transition.finished.finally(() => { transitionendHandler() THUMBNAIL.style.viewTransitionName = null }) } else { IMAGE.style.opacity = '0' requestAnimationFrame(transitionendHandler) } } else { transitionendHandler() } } /** * Select a specific slide by index * * @param {number} index - Index of the slide to select */ const select = (index) => { if (!isOpen()) { throw new Error("Oops, I'm closed.") } if (typeof index !== 'number' || isNaN(index)) { throw new Error('Oops, no slide specified.') } const GROUP = STATE.GROUPS[STATE.activeGroup] const triggerElements = GROUP.triggerElements if (index === STATE.currentIndex) { throw new Error(`Oops, slide ${index} is already selected.`) } if (index < 0 || index >= triggerElements.length) { throw new Error(`Oops, I can't find slide ${index}.`) } const OLD_INDEX = STATE.currentIndex STATE.currentIndex = index if (GROUP.sliderElements[index]) { loadSlide(STATE, index) } else { createSlide(STATE, index) createImage(STATE, GROUP.triggerElements[index], index, () => { loadImage(STATE, index) }) loadSlide(STATE, index) } updateOffset(STATE) updateSliderNavigationStatus(STATE) updateCounter(STATE) // Execute slideChange hook PLUGIN_MANAGER.executeHook('slideChange', { index, oldIndex: OLD_INDEX, state: STATE }) if (index < OLD_INDEX) { preload(STATE, createSlide, createImage, loadImage, index - 1) } else { preload(STATE, createSlide, createImage, loadImage, index + 1) } leaveSlide(STATE, OLD_INDEX) // Create and dispatch a new event dispatchCustomEvent(STATE.lightbox, 'select') } /** * Select the previous slide */ const previous = () => { if (STATE.currentIndex > 0) { select(STATE.currentIndex - 1) } } /** * Select the next slide */ const next = () => { const { triggerElements } = STATE.GROUPS[STATE.activeGroup] if (STATE.currentIndex < triggerElements.length - 1) { select(STATE.currentIndex + 1) } } /** * Bind specified events */ const bindEvents = () => { const actions = { close, previous, next, updateOffset: () => updateOffset(STATE) } // Create handlers with state and actions keydownHandler = createKeydownHandler(STATE, actions) clickHandler = createClickHandler(STATE, actions) resizeHandler = createResizeHandler(STATE, () => updateOffset(STATE)) const updateAfterDragHandler = () => updateAfterDrag(STATE, actions) const pinchZoomHandler = (img) => pinchZoom(STATE, img) const doSwipeHandler = () => doSwipe(STATE) const resetZoomHandler = (img) => resetZoom(STATE, img) pointerdownHandler = createPointerdownHandler(STATE) pointermoveHandler = createPointermoveHandler(STATE, pinchZoomHandler, doSwipeHandler) pointerupHandler = createPointerupHandler(STATE, resetZoomHandler, updateAfterDragHandler) BROWSER_WINDOW.addEventListener('keydown', keydownHandler) BROWSER_WINDOW.addEventListener('resize', resizeHandler) // Popstate event BROWSER_WINDOW.addEventListener('popstate', close) // Check for any OS level changes to the prefers reduced motion preference MOTIONQUERY.addEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY)) // Click event STATE.lightbox.addEventListener('click', clickHandler) // Pointer events STATE.lightbox.addEventListener('pointerdown', pointerdownHandler, { passive: false }) STATE.lightbox.addEventListener('pointerup', pointerupHandler, { passive: true }) STATE.lightbox.addEventListener('pointermove', pointermoveHandler, { passive: false }) } /** * Unbind specified events */ const unbindEvents = () => { BROWSER_WINDOW.removeEventListener('keydown', keydownHandler) BROWSER_WINDOW.removeEventListener('resize', resizeHandler) // Popstate event BROWSER_WINDOW.removeEventListener('popstate', close) // Check for any OS level changes to the prefers reduced motion preference MOTIONQUERY.removeEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY)) // Click event STATE.lightbox.removeEventListener('click', clickHandler) // Pointer events STATE.lightbox.removeEventListener('pointerdown', pointerdownHandler) STATE.lightbox.removeEventListener('pointerup', pointerupHandler) STATE.lightbox.removeEventListener('pointermove', pointermoveHandler) } /** * Destroy Parvus */ const destroy = () => { if (!STATE.lightbox) { return } if (isOpen()) { close() } // Add setTimeout to ensure all possible close transitions are completed setTimeout(() => { unbindEvents() // Remove all registered event listeners for custom events const eventTypes = [ 'open', 'close', 'select', 'destroy' ] eventTypes.forEach(eventType => { const listeners = STATE.lightbox._listeners?.[eventType] || [] listeners.forEach(listener => { STATE.lightbox.removeEventListener(eventType, listener) }) }) // Remove event listeners from trigger elements const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll('.parvus-trigger') LIGHTBOX_TRIGGER_ELS.forEach(el => { el.removeEventListener('click', triggerParvus) el.classList.remove('parvus-trigger') if (STATE.config.zoomIndicator) { removeZoomIndicator(el) } if (el.dataset.group) { delete el.dataset.group } }) // Create and dispatch a new event dispatchCustomEvent(STATE.lightbox, 'destroy') STATE.lightbox.remove() // Remove references STATE.lightbox = null STATE.lightboxOverlay = null STATE.toolbar = null STATE.toolbarLeft = null STATE.toolbarRight = null STATE.controls = null STATE.previousButton = null STATE.nextButton = null STATE.closeButton = null STATE.counter = null // Remove group data Object.keys(STATE.GROUPS).forEach(groupKey => { const group = STATE.GROUPS[groupKey] if (group && group.contentElements) { group.contentElements.forEach(content => { if (content && content.tagName === 'IMG') { content.src = '' content.srcset = '' } }) } delete STATE.GROUPS[groupKey] }) // Reset variables STATE.groupIdCounter = 0 STATE.newGroup = null STATE.activeGroup = null STATE.currentIndex = 0 }, 1000) } /** * Check if Parvus is open * * @returns {boolean} - True if Parvus is open, otherwise false */ const isOpen = () => { return STATE.lightbox?.hasAttribute('open') } /** * Get the current index * * @returns {number} - The current index */ const getCurrentIndex = () => { return STATE.currentIndex } /** * Bind a specific event listener * * @param {String} eventName - The name of the event to bind * @param {Function} callback - The callback function */ const on = (eventName, callback) => { addEventListener(STATE.lightbox, eventName, callback) } /** * Unbind a specific event listener * * @param {String} eventName - The name of the event to unbind * @param {Function} callback - The callback function */ const off = (eventName, callback) => { removeEventListener(STATE.lightbox, eventName, callback) } /** * Use a plugin * * @param {Object} plugin - Plugin object * @param {Object} options - Plugin options */ const use = (plugin, options = {}) => { PLUGIN_MANAGER.register(plugin, options) } /** * Add a hook callback * * @param {String} hookName - Hook name * @param {Function} callback - Callback function */ const addHook = (hookName, callback) => { PLUGIN_MANAGER.addHook(hookName, callback) } /** * Get registered plugins * * @returns {Array} Array of plugin names */ const getPlugins = () => { return PLUGIN_MANAGER.getPlugins() } /** * Init */ const init = () => { // Merge user options into defaults STATE.config = mergeOptions(userOptions) reducedMotionCheck(STATE, MOTIONQUERY) // Install plugins with context const pluginContext = { state: STATE, on: addEventListener, addHook: PLUGIN_MANAGER.addHook.bind(PLUGIN_MANAGER), config: STATE.config } PLUGIN_MANAGER.install(pluginContext) if (STATE.config.gallerySelector !== null) { // Get a list of all `gallerySelector` elements within the document const GALLERY_ELS = document.querySelectorAll(STATE.config.gallerySelector) // Execute a few things once per element GALLERY_ELS.forEach((galleryEl, index) => { const GALLERY_INDEX = index // Get a list of all `selector` elements within the `gallerySelector` const LIGHTBOX_TRIGGER_GALLERY_ELS = galleryEl.querySelectorAll(STATE.config.selector) // Execute a few things once per element LIGHTBOX_TRIGGER_GALLERY_ELS.forEach((lightboxTriggerEl) => { lightboxTriggerEl.setAttribute('data-group', `parvus-gallery-${GALLERY_INDEX}`) add(lightboxTriggerEl) }) }) } // Get a list of all `selector` elements outside or without the `gallerySelector` const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(`${STATE.config.selector}:not(.parvus-trigger)`) LIGHTBOX_TRIGGER_ELS.forEach(add) } init() return { init, open, close, select, previous, next, currentIndex: getCurrentIndex, add, remove, destroy, isOpen, on, off, use, addHook, getPlugins } } ================================================ FILE: src/js/ui/lightbox.js ================================================ /** * UI Components Module * * Handles creation of lightbox, toolbar, slider and slides */ /** * Create the lightbox * * @param {Object} state - The application state * @returns {void} */ export const createLightbox = (state) => { const { config } = state // Use DocumentFragment to batch DOM operations const fragment = document.createDocumentFragment() // Create the lightbox container state.lightbox = document.createElement('dialog') state.lightbox.setAttribute('role', 'dialog') state.lightbox.setAttribute('aria-modal', 'true') state.lightbox.setAttribute('aria-label', config.l10n.lightboxLabel) state.lightbox.classList.add('parvus') // Create the lightbox overlay container state.lightboxOverlay = document.createElement('div') state.lightboxOverlay.classList.add('parvus__overlay') // Create the toolbar state.toolbar = document.createElement('div') state.toolbar.className = 'parvus__toolbar' // Create the toolbar items state.toolbarLeft = document.createElement('div') state.toolbarRight = document.createElement('div') // Create the controls state.controls = document.createElement('div') state.controls.className = 'parvus__controls' state.controls.setAttribute('role', 'group') state.controls.setAttribute('aria-label', config.l10n.controlsLabel) // Create the close button state.closeButton = document.createElement('button') state.closeButton.className = 'parvus__btn parvus__btn--close' state.closeButton.setAttribute('type', 'button') state.closeButton.setAttribute('aria-label', config.l10n.closeButtonLabel) state.closeButton.innerHTML = config.closeButtonIcon // Create the previous button state.previousButton = document.createElement('button') state.previousButton.className = 'parvus__btn parvus__btn--previous' state.previousButton.setAttribute('type', 'button') state.previousButton.setAttribute('aria-label', config.l10n.previousButtonLabel) state.previousButton.innerHTML = config.previousButtonIcon // Create the next button state.nextButton = document.createElement('button') state.nextButton.className = 'parvus__btn parvus__btn--next' state.nextButton.setAttribute('type', 'button') state.nextButton.setAttribute('aria-label', config.l10n.nextButtonLabel) state.nextButton.innerHTML = config.nextButtonIcon // Create the counter state.counter = document.createElement('div') state.counter.className = 'parvus__counter' // Add the control buttons to the controls state.controls.append(state.closeButton, state.previousButton, state.nextButton) // Add the counter to the left toolbar item state.toolbarLeft.appendChild(state.counter) // Add the controls to the right toolbar item state.toolbarRight.appendChild(state.controls) // Add the toolbar items to the toolbar state.toolbar.append(state.toolbarLeft, state.toolbarRight) // Add the overlay and the toolbar to the lightbox state.lightbox.append(state.lightboxOverlay, state.toolbar) fragment.appendChild(state.lightbox) // Add to document body document.body.appendChild(fragment) } /** * Create a slider * * @param {Object} state - The application state * @returns {void} */ export const createSlider = (state) => { const SLIDER = document.createElement('div') SLIDER.className = 'parvus__slider' // Update the slider reference in GROUPS state.GROUPS[state.activeGroup].slider = SLIDER // Add the slider to the lightbox container state.lightbox.appendChild(SLIDER) } /** * Get next slide index * * @param {Object} state - The application state * @param {Number} currentIndex - Current slide index * @returns {number} Index of the next available slide or -1 if none found */ export const getNextSlideIndex = (state, currentIndex) => { const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements const TOTAL_SLIDE_ELEMENTS = SLIDE_ELEMENTS.length for (let i = currentIndex + 1; i < TOTAL_SLIDE_ELEMENTS; i++) { if (SLIDE_ELEMENTS[i] !== undefined) { return i } } return -1 } /** * Get previous slide index * * @param {Object} state - The application state * @param {number} currentIndex - Current slide index * @returns {number} Index of the previous available slide or -1 if none found */ export const getPreviousSlideIndex = (state, currentIndex) => { const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements for (let i = currentIndex - 1; i >= 0; i--) { if (SLIDE_ELEMENTS[i] !== undefined) { return i } } return -1 } /** * Create a slide * * @param {Object} state - The application state * @param {Number} index - The index of the slide * @returns {void} */ export const createSlide = (state, index) => { if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) { return } const FRAGMENT = document.createDocumentFragment() const SLIDE_ELEMENT = document.createElement('div') const SLIDE_ELEMENT_CONTENT = document.createElement('div') const GROUP = state.GROUPS[state.activeGroup] const TOTAL_TRIGGER_ELEMENTS = GROUP.triggerElements.length SLIDE_ELEMENT.className = 'parvus__slide' SLIDE_ELEMENT.style.cssText = ` position: absolute; left: ${index * 100}%; ` SLIDE_ELEMENT.setAttribute('aria-hidden', 'true') // Add accessibility attributes if gallery has multiple slides if (TOTAL_TRIGGER_ELEMENTS > 1) { SLIDE_ELEMENT.setAttribute('role', 'group') SLIDE_ELEMENT.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`) } SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT) FRAGMENT.appendChild(SLIDE_ELEMENT) GROUP.sliderElements[index] = SLIDE_ELEMENT // Insert the slide element based on index position if (index >= state.currentIndex) { // Insert the slide element after the current slide const NEXT_SLIDE_INDEX = getNextSlideIndex(state, index) if (NEXT_SLIDE_INDEX !== -1) { GROUP.sliderElements[NEXT_SLIDE_INDEX].before(SLIDE_ELEMENT) } else { GROUP.slider.appendChild(SLIDE_ELEMENT) } } else { // Insert the slide element before the current slide const PREVIOUS_SLIDE_INDEX = getPreviousSlideIndex(state, index) if (PREVIOUS_SLIDE_INDEX !== -1) { GROUP.sliderElements[PREVIOUS_SLIDE_INDEX].after(SLIDE_ELEMENT) } else { GROUP.slider.prepend(SLIDE_ELEMENT) } } } /** * Update counter * * @param {Object} state - The application state * @returns {void} */ export const updateCounter = (state) => { state.counter.textContent = `${state.currentIndex + 1}/${state.GROUPS[state.activeGroup].triggerElements.length}` } /** * Update Attributes * * @param {Object} state - The application state * @returns {void} */ export const updateAttributes = (state) => { const TRIGGER_ELEMENTS = state.GROUPS[state.activeGroup].triggerElements const TOTAL_TRIGGER_ELEMENTS = TRIGGER_ELEMENTS.length const SLIDER = state.GROUPS[state.activeGroup].slider const SLIDER_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements const IS_DRAGGABLE = SLIDER.classList.contains('parvus__slider--is-draggable') // Add draggable class if necessary if ((state.config.simulateTouch && state.config.swipeClose && !IS_DRAGGABLE) || (state.config.simulateTouch && TOTAL_TRIGGER_ELEMENTS > 1 && !IS_DRAGGABLE)) { SLIDER.classList.add('parvus__slider--is-draggable') } else { SLIDER.classList.remove('parvus__slider--is-draggable') } // Add extra output for screen reader if there is more than one slide if (TOTAL_TRIGGER_ELEMENTS > 1) { SLIDER.setAttribute('role', 'region') SLIDER.setAttribute('aria-roledescription', 'carousel') SLIDER.setAttribute('aria-label', state.config.l10n.sliderLabel) SLIDER_ELEMENTS.forEach((sliderElement, index) => { sliderElement.setAttribute('role', 'group') sliderElement.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`) }) } else { SLIDER.removeAttribute('role') SLIDER.removeAttribute('aria-roledescription') SLIDER.removeAttribute('aria-label') SLIDER_ELEMENTS.forEach((sliderElement) => { sliderElement.removeAttribute('role') sliderElement.removeAttribute('aria-label') }) } // Show or hide buttons if (TOTAL_TRIGGER_ELEMENTS === 1) { state.counter.setAttribute('aria-hidden', 'true') state.previousButton.setAttribute('aria-hidden', 'true') state.nextButton.setAttribute('aria-hidden', 'true') } else { state.counter.removeAttribute('aria-hidden') state.previousButton.removeAttribute('aria-hidden') state.nextButton.removeAttribute('aria-hidden') } } /** * Update slider navigation status * * @param {Object} state - The application state * @returns {void} */ export const updateSliderNavigationStatus = (state) => { const { triggerElements } = state.GROUPS[state.activeGroup] const TOTAL_TRIGGER_ELEMENTS = triggerElements.length if (TOTAL_TRIGGER_ELEMENTS <= 1) { return } // Determine navigation state const FIRST_SLIDE = state.currentIndex === 0 const LAST_SLIDE = state.currentIndex === TOTAL_TRIGGER_ELEMENTS - 1 // Set previous button state const PREV_DISABLED = FIRST_SLIDE ? 'true' : null if ((state.previousButton.getAttribute('aria-disabled') === 'true') !== !!PREV_DISABLED) { PREV_DISABLED ? state.previousButton.setAttribute('aria-disabled', 'true') : state.previousButton.removeAttribute('aria-disabled') } // Set next button state const NEXT_DISABLED = LAST_SLIDE ? 'true' : null if ((state.nextButton.getAttribute('aria-disabled') === 'true') !== !!NEXT_DISABLED) { NEXT_DISABLED ? state.nextButton.setAttribute('aria-disabled', 'true') : state.nextButton.removeAttribute('aria-disabled') } } ================================================ FILE: src/js/ui/zoom-indicator.js ================================================ /** * Add zoom indicator to element * * @param {HTMLElement} el - The element to add the zoom indicator to * @param {Object} config - Options object */ export const addZoomIndicator = (el, config) => { if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') === null) { const LIGHTBOX_INDICATOR_ICON = document.createElement('div') LIGHTBOX_INDICATOR_ICON.className = 'parvus-zoom__indicator' LIGHTBOX_INDICATOR_ICON.innerHTML = config.lightboxIndicatorIcon el.appendChild(LIGHTBOX_INDICATOR_ICON) } } /** * Remove zoom indicator for element * * @param {HTMLElement} el - The element to remove the zoom indicator to */ export const removeZoomIndicator = (el) => { if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') !== null) { const LIGHTBOX_INDICATOR_ICON = el.querySelector('.parvus-zoom__indicator') el.removeChild(LIGHTBOX_INDICATOR_ICON) } } ================================================ FILE: src/l10n/de.js ================================================ export default { 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.', lightboxLoadingIndicatorLabel: 'Bild wird geladen', lightboxLoadingError: 'Das angeforderte Bild kann nicht geladen werden.', controlsLabel: 'Steuerungen', previousButtonLabel: 'Vorheriges Bild', nextButtonLabel: 'Nächstes Bild', closeButtonLabel: 'Dialogfenster schließen', sliderLabel: 'Bilder', slideLabel: 'Bild' } ================================================ FILE: src/l10n/en.js ================================================ export default { 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' } ================================================ FILE: src/l10n/fr.js ================================================ export default { lightboxLabel: 'Il s\'agit d\'une boîte de dialogue superposée au contenu principal de la page. La fenêtre modale affiche l\'image agrandie. Appuyez sur la touche Échap pour fermer la fenêtre modale et revenir à l\'endroit où vous étiez sur la page.', lightboxLoadingIndicatorLabel: 'Chargement de l\'image', lightboxLoadingError: 'L\'image demandée ne peut pas être chargée', controlsLabel: 'Contrôles', previousButtonLabel: 'Image précédente', nextButtonLabel: 'Image suivante', closeButtonLabel: 'Fermer la fenêtre de dialogue', sliderLabel: 'Images', slideLabel: 'Image' } ================================================ FILE: src/l10n/it.js ================================================ export default { lightboxLabel: 'Questa è una finestra di dialogo che si sovrappone al contenuto principale della pagina. La modale mostra l\'immagine ingrandita. Premendo il tasto Escape si chiuderà la modale e tornerai dove eri sulla pagina.', lightboxLoadingIndicatorLabel: 'Caricamento immagine', lightboxLoadingError: 'Impossibile caricare l\'immagine richiesta.', controlsLabel: 'Controlli', previousButtonLabel: 'Immagine precedente', nextButtonLabel: 'Immagine successiva', closeButtonLabel: 'Chiudi finestra di dialogo', sliderLabel: 'Immagini', slideLabel: 'Immagine' } ================================================ FILE: src/l10n/nl.js ================================================ export default { 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.', lightboxLoadingIndicatorLabel: 'Afbeelding wordt geladen', lightboxLoadingError: 'De gevraagde afbeelding kan niet worden geladen.', controlsLabel: 'Bedieningselementen', previousButtonLabel: 'Vorige afbeelding', nextButtonLabel: 'Volgende afbeelding', closeButtonLabel: 'Sluit dialoogvenster', sliderLabel: 'Afbeeldingen', slideLabel: 'Afbeelding' } ================================================ FILE: src/scss/parvus.scss ================================================ :root { // Transition --parvus-transition-duration: 0.3s; --parvus-transition-timing-function: cubic-bezier(0.62, 0.16, 0.13, 1.01); // Overlay --parvus-background-color: hsl(23deg 44% 96%); --parvus-color: hsl(228deg 24% 23%); // Button --parvus-btn-background-color: hsl(228deg 24% 23%); --parvus-btn-color: hsl(0deg 0% 100%); --parvus-btn-hover-background-color: hsl(229deg 24% 33%); --parvus-btn-hover-color: hsl(0deg 0% 100%); --parvus-btn-disabled-background-color: hsl(229deg 24% 33% / 60%); --parvus-btn-disabled-color: hsl(0deg 0% 100%); // Caption --parvus-caption-background-color: transparent; --parvus-caption-color: hsl(228deg 24% 23%); // Copyright --parvus-copyright-background-color: hsl(0deg 0% 100% / 80%); --parvus-copyright-color: hsl(228deg 24% 23%); // Loading error --parvus-loading-error-background-color: hsl(0deg 0% 100%); --parvus-loading-error-color: hsl(228deg 24% 23%); // Loader --parvus-loader-background-color: hsl(23deg 40% 96%); --parvus-loader-color: hsl(228deg 24% 23%); } ::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 * */ .parvus-trigger:has(img) { display: block; position: relative; & .parvus-zoom__indicator { align-items: center; background-color: var(--parvus-btn-background-color); color: var(--parvus-btn-color); display: flex; justify-content: center; padding: 0.5rem; position: absolute; inset-inline-end: 0.5rem; inset-block-start: 0.5rem; } & img { display: block; } } /** * Parvus * */ .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; &::backdrop { display:none; } & *, & *::before, & *::after { box-sizing: border-box; } &__overlay { background-color: var(--parvus-background-color); color: var(--parvus-color); inset: 0; position: absolute; } &__slider { inset: 0; position: absolute; transform: translateZ(0); @media screen and (prefers-reduced-motion: no-preference) { &--animate:not(&--is-dragging) { transition: transform var(--parvus-transition-duration) var(--parvus-transition-timing-function); will-change: transform; } } &--is-draggable { cursor: grab; touch-action: pan-y pinch-zoom; } &--is-dragging { cursor: grabbing; touch-action: none; } } &__slide { block-size: 100%; contain: layout; display: grid; inline-size: 100%; padding-block: 1rem; padding-inline: 1rem; place-items: center; & img { block-size: auto; display: block; inline-size: auto; margin-inline: auto; transform: translateZ(0); } } &__content { position: relative; &--error { background-color: var(--parvus-loading-error-background-color); color: var(--parvus-loading-error-color); padding-block: 0.5rem; padding-inline: 1rem; } } &__caption { background-color: var(--parvus-caption-background-color); color: var(--parvus-caption-color); padding-block-start: 0.5rem; text-align: start; } &__copyright { background-color: var(--parvus-copyright-background-color); color: var(--parvus-copyright-color); inset-block-end: 0; inset-inline-end: 0; padding-inline: 0.25rem; position: absolute; } &__loader { display: inline-block; block-size: 6.25rem; inset-inline-start: 50%; position: absolute; inset-block-start: 50%; transform: translate(-50%, -50%); inline-size: 6.25rem; &::before { animation: spin 1s infinite linear; border-radius: 100%; border: 0.25rem solid var(--parvus-loader-background-color); border-block-start-color: var(--parvus-loader-color); content: ''; inset: 0; position: absolute; z-index: 1; } } &__toolbar { align-items: center; display: flex; inset-block-start: 1rem; inset-inline: 1rem; justify-content: space-between; pointer-events: none; position: absolute; view-transition-name: toolbar; z-index: 8; & > * { pointer-events: auto; } } &__controls { align-items: center; display: flex; gap: 0.5rem; } &__btn { appearance: none; background-color: var(--parvus-btn-background-color); background-image: none; border-radius: 0; border: 0.0625rem solid transparent; color: var(--parvus-btn-color); cursor: pointer; display: flex; font: inherit; padding: 0.3125rem; position: relative; touch-action: manipulation; will-change: transform, opacity; z-index: 7; &:hover, &:focus-visible { background-color: var(--parvus-btn-hover-background-color); color: var(--parvus-btn-hover-color); } &--previous { inset-inline-start: 0; position: absolute; inset-block-start: calc(50svh - 1rem); // 50svh - paddingTop from .parvus__slide transform: translateY(-50%); } &--next { position: absolute; inset-inline-end: 0; inset-block-start: calc(50svh - 1rem); // 50svh - paddingTop from .parvus__slide transform: translateY(-50%); } & svg { pointer-events: none; } &[aria-hidden='true'] { display: none; } &[aria-disabled='true'] { background-color: var(--parvus-btn-disabled-background-color); color: var(--parvus-btn-disabled-color); } } &__counter { position: relative; z-index: 7; &[aria-hidden='true'] { display: none; } } @media screen and (prefers-reduced-motion: no-preference) { &__overlay, &__counter, &__btn, &__caption, &__copyright { 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; } &__copyright { transition-delay: var(--parvus-transition-duration); .parvus--is-closing &, .parvus--is-vertical-closing &, .parvus--is-zooming & { transition-delay: 0s; transition-duration: 0s; } } &--is-opening, &--is-closing { & .parvus__overlay, & .parvus__counter, & .parvus__btn, & .parvus__caption, & .parvus__copyright { opacity: 0; } } &--is-vertical-closing, &--is-zooming { & .parvus__counter, & .parvus__btn:not(.parvus__btn--previous, .parvus__btn--next) { transform: translateY(-100%); opacity: 0; } & .parvus__btn--previous { transform: translate(-100%, -50%); opacity: 0; } & .parvus__btn--next { transform: translate(100%, -50%); opacity: 0; } & .parvus__caption { transform: translateY(100%); opacity: 0; } & .parvus__copyright { opacity: 0; } } } } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } ================================================ FILE: test/test.html ================================================ Parvus - Feature Showcase

Parvus - Feature Showcase

This page demonstrates all features and options of Parvus.

1. Basic Usage

Simple image with no caption or grouping.

2. Captions - Direct Attribute

Using data-caption attribute.

3. Captions - Reference by ID

Using data-caption-id to reference an external element.

4. Copyright - Direct Attribute

Using data-copyright attribute (new feature!).

5. Copyright - Reference by ID

Using data-copyright-id to reference an external element.

6. Gallery with data-group

Images grouped together using data-group="Netherlands".

7. Gallery with gallerySelector

Images automatically grouped by parent selector (no data-group needed).

8. Responsive Images with srcset

Using data-srcset for responsive images.

9. Text Links

Opening images from text links (no thumbnail image).

10. Button Elements

Using buttons instead of links with data-target attribute.

11. Mixed Groups

Different groups on the same page.

12. Localization

Using different language files. This example uses German (l10n: de).

13. Plugin Example

Custom plugin that adds a button and tracks slide changes (check console). Note: The plugin is only registered for these specific images, not for the other galleries above.