[
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: [deoostfrees]\npatreon: # Replace with a single Patreon username\nopen_collective: # Replace with a single Open Collective username\nko_fi: # Replace with a single Ko-fi username\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\notechie: # Replace with a single Otechie username\ncustom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']\n"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\n.vscode\nnode_modules\n"
  },
  {
    "path": ".stylelintrc",
    "content": "{\n  \"extends\": [\n    \"stylelint-config-standard-scss\"\n  ],\n  \"plugins\": [\n    \"stylelint-scss\",\n    \"stylelint-use-logical\"\n  ],\n  \"rules\": {\n    \"at-rule-no-unknown\": null,\n    \"scss/at-rule-no-unknown\": true,\n    \"color-hex-length\": \"long\",\n    \"comment-whitespace-inside\": null,\n    \"no-descending-specificity\": null,\n    \"shorthand-property-no-redundant-values\": [true, {\"severity\": \"warning\"}],\n    \"declaration-no-important\": true,\n    \"no-duplicate-at-import-rules\": true,\n    \"selector-max-id\": 0,\n    \"declaration-block-no-duplicate-properties\": true,\n    \"rule-empty-line-before\": [\"always-multi-line\", {\"ignore\": [\"after-comment\"]}],\n    \"value-keyword-case\": \"lower\",\n    \"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 }],\n    \"declaration-block-no-redundant-longhand-properties\": null,\n    \"csstools/use-logical\": true\n  }\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## [3.1.0] - 2026-04-18\n\n### Added\n\n- Add copyright information to an image e598627 @deoostfrees\n- Add plugin system with lifecycle hooks ae8203d 2ea0794 86f1056 @deoostfrees\n- Add support for captions via ID reference f6e1b8c @deoostfrees\n- Add Italian translations 30c42e2 ea54ca2 @conlaccento\n- Add French translations 4d04d8d @slolo2000\n\n### Changed\n\n- Use `hsl()` instead of `hsla()` 4ac8cb6 @deoostfrees\n- Modularize codebase and improve maintainability 951d30a @deoostfrees\n\n## [3.0.0] - 2025-03-16\n\n### Added\n\n- Pinch zoom gestures 4a591e7 4a8355a fd4ebf1 4e472ef 49c5b16 d27efd9 @deoostfrees #42\n- Option to make the zoom indicator optional e65d5c7 @deoostfrees #62\n\n### Changed\n\n- Use the native HTML `dialog` element e703293 @deoostfrees #60\n- Use the View Transitions API for the zoom in/ out animation 11e183f @deoostfrees\n- Use pointer events instead of mouse and touch events b4941cf @deoostfrees\n\n### Removed\n\n- **Breaking:** The custom event `detail` property 4ea8e38 @deoostfrees\n- The `transitionDuration` option. This option is now also set via the available CSS custom property 11e183f @deoostfrees\n- The `transitionTimingFunction` option. This option is now also set via the available CSS custom property 11e183f @deoostfrees\n- The `loadEmpty` option. The internal `add` function now creates the lightbox 98e41b5 @deoostfrees\n- The custom `close` event. The native HTML `dialog` element has its own `close` event dba4678 @deoostfrees\n\n## [2.6.0] - 2024-06-05\n\n### Changed\n\n- Run `change` event listener for `reducedMotionCheck` only when the lightbox is open 083a0e7 @deoostfrees\n\n### Fixed\n\n- Avoid unintentionally moving the image when dragging 96ff56e @deoostfrees #59\n- Relationship between caption and image 76df207 @deoostfrees\n\n## [2.5.3] - 2024-04-27\n\n### Fixed\n\n- Remove optional files field in package.json to include all files via NPM 819e132 @deoostfrees\n\n## [2.5.2] - 2024-04-27\n\n### Fixed\n\n- Language file import afe86dc @deoostfrees #55\n\n## [2.5.1] - 2024-04-10\n\n### Fixed\n\n- Issue if no language options are set 2dbed4a @deoostfrees\n\n## [2.5.0] - 2024-04-07\n\n### Added\n\n- Option to load an empty lightbox (even if there are no elements) 9a180fc @deoostfrees a436a81 @drhino\n- Fallback to the default language 39e1ae0 @drhino\n- Dutch translation 7476426 @drhino\n\n### Changed\n\n- **Breaking:** Rename some CSS custom properties 8b43c66  8ba1f00 @deoostfrees\n\n### Removed\n\n- Slide animation when first/ last slide is visible 4df766b @deoostfrees #52\n\n## [2.4.0] - 2023-07-20\n\n### Added\n\n- Option to hide the browser scrollbar #47\n\n### Changed\n\n- Added an internal function to create and dispatch a new event\n- Disabled buttons are no longer visually hidden\n- Focus is no longer moved automatically\n- CSS styles are now moved from SVG to the actual elements\n\n### Removed\n\n- Custom typography styles\n\n### Fixed\n\n- Load the srcset before the src, add sizes attribute #49\n\n## [2.3.3] - 2023-05-30\n\n### Fixed\n\n- Animate current image and set focus back to the correct element in the default behavior of the `backFocus` option\n\n## [2.3.2] - 2023-05-30\n\n### Fixed\n\n- Set focus back to the correct element in the default behavior of the `backFocus` option\n\n## [2.3.1] - 2023-05-29\n\n### Fixed\n\n- The navigation buttons' visibility\n\n## [2.3.0] - 2023-05-27\n\n### Added\n\n- Changelog section to keep track of changes\n- Necessary outputs for screen reader support\n- CSS custom properties for captions and image loading error messages\n\n### Changed\n\n- Replaced the custom `copyObject()` function with the built-in `structuredClone()` method\n- Refactored code and comments to improve readability and optimize performance\n\n### Removed\n\n- The option for supported image file types as it is no longer necessary\n- The `scrollClose` option\n\n### Fixed\n\n- Non standard URLs can break Parvus #43\n"
  },
  {
    "path": "LICENSE.md",
    "content": "# The MIT License (MIT)\n\nCopyright (c) 2020-2026 Benjamin de Oostfrees\n\nPermission 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:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE 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.\n"
  },
  {
    "path": "README.md",
    "content": "# Parvus\n\nOverlays suck, but if you need one, consider using Parvus. Parvus is an open source, dependency free image lightbox with the goal of being accessible.\n\n![Screenshot of Parvus. It shows the first picture of a gallery.](https://rqrauhvmra.com/parvus/parvus-3-1.png)\n\n[Open in CodePen](https://codepen.io/collection/DwLBpz)\n\n## Table of Contents\n\n- [Installation](#installation)\n  - [Download](#download)\n  - [Package Managers](#package-managers)\n- [Usage](#usage)\n  - [Captions](#captions)\n  - [Copyright](#copyright)\n  - [Gallery](#gallery)\n  - [Responsive Images](#responsive-images)\n  - [Localization](#localization)\n- [Options](#options)\n- [API](#api)\n- [Events](#events)\n- [Plugins](#plugins)\n  - [Using Plugins](#using-plugins)\n  - [Creating Plugins](#creating-plugins)\n  - [Plugin Hooks](#plugin-hooks)\n- [Browser Support](#browser-support)\n\n## Installation\n\n### Download\n\n- CSS:\n  - `dist/css/parvus.min.css` (minified) or\n  - `dist/css/parvus.css` (un-minified)\n- JavaScript:\n  - `dist/js/parvus.min.js` (minified) or\n  - `dist/js/parvus.js` (un-minified)\n\nLink the `.css` and `.js` files in your HTML:\n\n```html\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <title>Page title</title>\n\n  <!-- CSS -->\n  <link href=\"path/to/parvus.min.css\" rel=\"stylesheet\">\n</head>\n<body>\n  <!-- HTML content -->\n\n  <!-- JS -->\n  <script src=\"path/to/parvus.min.js\"></script>\n</body>\n</html>\n```\n\n### Package Managers\n\nYou can also install Parvus using npm or yarn:\n\n```sh\nnpm install parvus\n```\n\nor\n\n```sh\nyarn add parvus\n```\n\nAfter installation, import Parvus into your JavaScript codebase:\n\n```js\nimport Parvus from 'parvus'\n```\n\nBe sure to include the corresponding SCSS or CSS file.\n\n## Usage\n\nLink a thumbnail image with the class `lightbox` to a larger image:\n\n```html\n<a href=\"path/to/image.jpg\" class=\"lightbox\">\n  <img src=\"path/to/thumbnail.jpg\" alt=\"\">\n</a>\n```\n\nInitialize the script:\n\n```js\nconst prvs = new Parvus()\n```\n\n### Captions\n\nThere are three ways to add a caption to an image:\n\n#### Reference by ID\n\nYou can add an ID to your caption element and reference it from the trigger element using the `data-caption-id` attribute.\n\n```html\n<figure>\n  <a href=\"path/to/image.jpg\" class=\"lightbox\" data-caption-id=\"caption-1\">\n    <img src=\"path/to/thumbnail.jpg\" alt=\"\">\n  </a>\n\n  <figcaption id=\"caption-1\">\n    I'm a caption, and I live outside the link.\n  </figcaption>\n</figure>\n```\n\n#### Direct Attribute\n\nYou can add a `data-caption` attribute directly to the trigger element.\n\n```html\n<a href=\"path/to/image.jpg\" class=\"lightbox\" data-caption=\"I'm a simple caption\">\n  <img src=\"path/to/thumbnail.jpg\" alt=\"\">\n</a>\n```\n\n#### Child Element\n\nAlternatively, set the option `captionsSelector` to select a caption from a child element's `innerHTML`.\n\n```html\n<a href=\"path/to/image.jpg\" class=\"lightbox\">\n  <figure class=\"figure\">\n    <img src=\"path/to/thumbnail.jpg\" alt=\"\">\n\n    <figcaption class=\"figure__caption\">\n      I'm a caption inside a child element\n    </figcaption>\n  </figure>\n</a>\n```\n\n```js\nconst prvs = new Parvus({\n  captionsSelector: '.figure__caption',\n})\n```\n\n### Copyright\n\nThere are three ways to add copyright information to an image:\n\n#### Reference by ID\n\nYou can add an ID to your copyright element and reference it from the trigger element using the `data-copyright-id` attribute.\n\n```html\n<a href=\"path/to/image.jpg\" class=\"lightbox\" data-copyright-id=\"copyright-1\">\n  <img src=\"path/to/thumbnail.jpg\" alt=\"\">\n</a>\n\n<small id=\"copyright-1\" hidden>\n  © 2026 Photographer Name\n</small>\n```\n\n#### Direct Attribute\n\nYou can add a `data-copyright` attribute directly to the trigger element.\n\n```html\n<a href=\"path/to/image.jpg\" class=\"lightbox\" data-copyright=\"© 2026 Photographer Name\">\n  <img src=\"path/to/thumbnail.jpg\" alt=\"\">\n</a>\n```\n\n#### Child Element\n\nAlternatively, set the option `copyrightSelector` to select a copyright from a child element's `innerHTML`.\n\n```html\n<a href=\"path/to/image.jpg\" class=\"lightbox\">\n  <figure class=\"figure\">\n    <img src=\"path/to/thumbnail.jpg\" alt=\"\">\n\n    <small class=\"figure__copyright\">\n      © 2026 Photographer Name\n    </small>\n  </figure>\n</a>\n```\n\n```js\nconst prvs = new Parvus({\n  copyrightSelector: '.figure__copyright',\n})\n```\n\n### Gallery\n\nTo group related images into a set, add a `data-group` attribute:\n\n```html\n<a href=\"path/to/image.jpg\" class=\"lightbox\" data-group=\"Berlin\">\n  <img src=\"path/to/thumbnail.jpg\" alt=\"\">\n</a>\n\n<a href=\"path/to/image_2.jpg\" class=\"lightbox\" data-group=\"Berlin\">\n  <img src=\"path/to/thumbnail_2.jpg\" alt=\"\">\n</a>\n\n//...\n\n<a href=\"path/to/image_8.jpg\" class=\"lightbox\" data-group=\"Kassel\">\n  <img src=\"path/to/thumbnail_8.jpg\" alt=\"\">\n</a>\n```\n\nAlternatively, set the option `gallerySelector` to group all images with a specific class within a selector:\n\n```html\n<div class=\"gallery\">\n  <a href=\"path/to/image.jpg\" class=\"lightbox\">\n    <img src=\"path/to/thumbnail.jpg\" alt=\"\">\n  </a>\n\n  <a href=\"path/to/image_2.jpg\" class=\"lightbox\">\n    <img src=\"path/to/thumbnail_2.jpg\" alt=\"\">\n  </a>\n\n  // ...\n</div>\n```\n\n```js\nconst prvs = new Parvus({\n  gallerySelector: '.gallery',\n})\n```\n\n### Responsive Images\n\nSpecify different image sources and sizes using the `data-srcset` and `data-sizes` attributes:\n\n```html\n<a href=\"path/to/image.jpg\" class=\"lightbox\"\n\ndata-srcset=\"path/to/small.jpg 700w,\n             path/to/medium.jpg 1000w,\n             path/to/large.jpg 1200w\"\n\ndata-sizes=\"(max-width: 75em) 100vw,\n            75em\"\n>\n  <img src=\"path/to/thumbnail.jpg\" alt=\"\">\n</a>\n```\n\n### Localization\n\nImport the language module and set it as an option for localization:\n\n```js\nimport de from 'parvus/src/l10n/de'\n\nconst prvs = new Parvus({\n  l10n: de\n})\n```\n\n## Options\n\nCustomize Parvus by passing an options object when initializing:\n\n```js\nconst prvs = new Parvus({\n  // Clicking outside does not close Parvus\n  docClose: false\n})\n```\n\nAvailable options include:\n\n```js\n{\n  // Selector for elements that trigger Parvus\n  selector: '.lightbox',\n\n  // Selector for a group of elements combined as a gallery, overrides the `data-group` attribute.\n  gallerySelector: null,\n\n  // Display zoom indicator\n  zoomIndicator: true,\n\n  // Display captions if available\n  captions: true,\n\n  // Selector for the element where the caption is displayed; use \"self\" for the `a` tag itself.\n  captionsSelector: 'self',\n\n  // Attribute to get the caption from\n  captionsAttribute: 'data-caption',\n\n  // Display copyright if available\n  copyright: true,\n\n  // Selector for the element where the copyright is displayed; use \"self\" for the `a` tag itself.\n  copyrightSelector: 'self',\n\n  // Attribute to get the copyright from\n  copyrightAttribute: 'data-copyright',\n\n  // Clicking outside closes Parvus\n  docClose: true,\n\n  // Close Parvus by swiping up/down\n  swipeClose: true,\n\n  // Accept mouse events like touch events (click and drag to change slides)\n  simulateTouch: true,\n\n  // Touch dragging threshold in pixels\n  threshold: 100,\n\n  // Hide browser scrollbar\n  hideScrollbar: true,\n\n  // Icons\n  lightboxIndicatorIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3\"/></svg>',\n  previousButtonIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><path stroke=\"none\" d=\"M0 0h24v24H0z\"/><polyline points=\"15 6 9 12 15 18\" /></svg>',\n  nextButtonIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><path stroke=\"none\" d=\"M0 0h24v24H0z\"/><polyline points=\"9 6 15 12 9 18\" /></svg>',\n  closeButtonIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>',\n\n  // Localization of strings\n  l10n: en\n}\n```\n\n## API\n\nParvus provides the following API functions:\n\n| Function | Description |\n| --- | --- |\n| `open(element)` | Open the specified `element` (DOM element) |\n| `close()` | Close Parvus |\n| `previous()` | Show the previous image |\n| `next()` | Show the next image |\n| `select(index)` | Select a slide with the specified `index` (integer) |\n| `add(element)` | Add the specified `element` (DOM element) |\n| `remove(element)` | Remove the specified `element` (DOM element) |\n| `destroy()` | Destroy Parvus |\n| `isOpen()` | Check if Parvus is currently open |\n| `currentIndex()` | Get the index of the currently displayed slide |\n| `use(plugin, options)` | Register a plugin |\n| `addHook(hookName, callback)` | Add a hook callback |\n| `getPlugins()` | Get list of registered plugins |\n\n## Events\n\nBind and unbind events using the `.on()` and `.off()` methods:\n\n```js\nconst prvs = new Parvus()\n\nconst listener = () => {\n  console.log('eventName happened')\n}\n\n// Bind event listener\nprvs.on(eventName, listener)\n\n// Unbind event listener\nprvs.off(eventName, listener)\n```\n\nAvailable events:\n\n| eventName | Description |\n| --- | --- |\n| `open` | Triggered after Parvus has opened |\n| `select` | Triggered when a slide is selected |\n| `close` | Triggered after Parvus has closed |\n| `destroy` | Triggered after Parvus has destroyed |\n\n## Plugins\n\nParvus supports a plugin system that allows you to extend its functionality.\n\n### Using Plugins\n\nTo use a plugin, call the `.use()` method after initialization:\n\n```js\nimport Parvus from 'parvus'\nimport MyPlugin from './my-plugin.js'\n\nconst prvs = new Parvus()\n\n// Register plugin\nprvs.use(MyPlugin, {\n  // Plugin-specific options\n  option1: 'value1',\n  option2: 'value2'\n})\n```\n\n### Creating Plugins\n\nA plugin is an object with a `name` and an `install` function:\n\n```js\nconst MyPlugin = {\n  name: 'MyPlugin',\n\n  install(parvus, options = {}) {\n    // Plugin initialization code\n    console.log('Plugin installed with options: ', options)\n  }\n}\n\nexport default MyPlugin\n```\n\n### Plugin Hooks\n\nPlugins can hook into various lifecycle events:\n\n| Hook Name | When Triggered | Provided Data |\n| --- | --- | --- |\n| `afterInit` | After lightbox DOM is created (once) | `{ state }` |\n| `afterOpen` | After lightbox opens | `{ element, state }` |\n| `afterClose` | After lightbox closes | `{ state }` |\n| `slideChange` | When slide changes | `{ index, oldIndex, state }` |\n\nExample using hooks:\n\n```js\nconst MyPlugin = {\n  name: 'MyPlugin',\n\n  install(parvus, options) {\n    // Add a custom button on init\n    parvus.addHook('afterInit', ({ state }) => {\n      const btn = document.createElement('button')\n\n      btn.classList.add('parvus__btn')\n      btn.classList.add('parvus__btn--my-plugin')\n      btn.textContent = 'Custom'\n      btn.type = 'button'\n\n      // Add to controls as first element\n      if (state.controls) {\n        state.controls.prepend(btn)\n      }\n    })\n\n    // Track slide changes\n    parvus.addHook('slideChange', ({ index, oldIndex }) => {\n      console.log(`Changed from slide ${oldIndex} to ${index}`)\n    })\n  }\n}\n```\n\n## Browser Support\n\nParvus is supported on the latest versions of the following browsers:\n\n- Chrome\n- Edge\n- Firefox\n- Safari\n"
  },
  {
    "path": "dist/css/parvus.css",
    "content": ":root {\n  --parvus-transition-duration: 0.3s;\n  --parvus-transition-timing-function: cubic-bezier(0.62, 0.16, 0.13, 1.01);\n  --parvus-background-color: hsl(23deg 44% 96%);\n  --parvus-color: hsl(228deg 24% 23%);\n  --parvus-btn-background-color: hsl(228deg 24% 23%);\n  --parvus-btn-color: hsl(0deg 0% 100%);\n  --parvus-btn-hover-background-color: hsl(229deg 24% 33%);\n  --parvus-btn-hover-color: hsl(0deg 0% 100%);\n  --parvus-btn-disabled-background-color: hsl(229deg 24% 33% / 60%);\n  --parvus-btn-disabled-color: hsl(0deg 0% 100%);\n  --parvus-caption-background-color: transparent;\n  --parvus-caption-color: hsl(228deg 24% 23%);\n  --parvus-copyright-background-color: hsl(0deg 0% 100% / 80%);\n  --parvus-copyright-color: hsl(228deg 24% 23%);\n  --parvus-loading-error-background-color: hsl(0deg 0% 100%);\n  --parvus-loading-error-color: hsl(228deg 24% 23%);\n  --parvus-loader-background-color: hsl(23deg 40% 96%);\n  --parvus-loader-color: hsl(228deg 24% 23%);\n}\n\n::view-transition-group(lightboximage) {\n  animation-duration: var(--parvus-transition-duration);\n  animation-timing-function: var(--parvus-transition-timing-function);\n  z-index: 7;\n}\n\n::view-transition-group(toolbar) {\n  z-index: 8;\n}\n\nbody:has(.parvus[open]) {\n  touch-action: none;\n}\n\n/**\n * Parvus trigger\n *\n */\n.parvus-trigger:has(img) {\n  display: block;\n  position: relative;\n}\n.parvus-trigger:has(img) .parvus-zoom__indicator {\n  align-items: center;\n  background-color: var(--parvus-btn-background-color);\n  color: var(--parvus-btn-color);\n  display: flex;\n  justify-content: center;\n  padding: 0.5rem;\n  position: absolute;\n  inset-inline-end: 0.5rem;\n  inset-block-start: 0.5rem;\n}\n.parvus-trigger:has(img) img {\n  display: block;\n}\n\n/**\n * Parvus\n *\n */\n.parvus {\n  background-color: transparent;\n  block-size: 100%;\n  border: 0;\n  box-sizing: border-box;\n  color: var(--parvus-color);\n  contain: strict;\n  inline-size: 100%;\n  inset: 0;\n  margin: 0;\n  max-block-size: unset;\n  max-inline-size: unset;\n  overflow: hidden;\n  overscroll-behavior: contain;\n  padding: 0;\n  position: fixed;\n}\n.parvus::backdrop {\n  display: none;\n}\n.parvus *, .parvus *::before, .parvus *::after {\n  box-sizing: border-box;\n}\n.parvus__overlay {\n  background-color: var(--parvus-background-color);\n  color: var(--parvus-color);\n  inset: 0;\n  position: absolute;\n}\n.parvus__slider {\n  inset: 0;\n  position: absolute;\n  transform: translateZ(0);\n}\n@media screen and (prefers-reduced-motion: no-preference) {\n  .parvus__slider--animate:not(.parvus__slider--is-dragging) {\n    transition: transform var(--parvus-transition-duration) var(--parvus-transition-timing-function);\n    will-change: transform;\n  }\n}\n.parvus__slider--is-draggable {\n  cursor: grab;\n  touch-action: pan-y pinch-zoom;\n}\n.parvus__slider--is-dragging {\n  cursor: grabbing;\n  touch-action: none;\n}\n.parvus__slide {\n  block-size: 100%;\n  contain: layout;\n  display: grid;\n  inline-size: 100%;\n  padding-block: 1rem;\n  padding-inline: 1rem;\n  place-items: center;\n}\n.parvus__slide img {\n  block-size: auto;\n  display: block;\n  inline-size: auto;\n  margin-inline: auto;\n  transform: translateZ(0);\n}\n.parvus__content {\n  position: relative;\n}\n.parvus__content--error {\n  background-color: var(--parvus-loading-error-background-color);\n  color: var(--parvus-loading-error-color);\n  padding-block: 0.5rem;\n  padding-inline: 1rem;\n}\n.parvus__caption {\n  background-color: var(--parvus-caption-background-color);\n  color: var(--parvus-caption-color);\n  padding-block-start: 0.5rem;\n  text-align: start;\n}\n.parvus__copyright {\n  background-color: var(--parvus-copyright-background-color);\n  color: var(--parvus-copyright-color);\n  inset-block-end: 0;\n  inset-inline-end: 0;\n  padding-inline: 0.25rem;\n  position: absolute;\n}\n.parvus__loader {\n  display: inline-block;\n  block-size: 6.25rem;\n  inset-inline-start: 50%;\n  position: absolute;\n  inset-block-start: 50%;\n  transform: translate(-50%, -50%);\n  inline-size: 6.25rem;\n}\n.parvus__loader::before {\n  animation: spin 1s infinite linear;\n  border-radius: 100%;\n  border: 0.25rem solid var(--parvus-loader-background-color);\n  border-block-start-color: var(--parvus-loader-color);\n  content: \"\";\n  inset: 0;\n  position: absolute;\n  z-index: 1;\n}\n.parvus__toolbar {\n  align-items: center;\n  display: flex;\n  inset-block-start: 1rem;\n  inset-inline: 1rem;\n  justify-content: space-between;\n  pointer-events: none;\n  position: absolute;\n  view-transition-name: toolbar;\n  z-index: 8;\n}\n.parvus__toolbar > * {\n  pointer-events: auto;\n}\n.parvus__controls {\n  align-items: center;\n  display: flex;\n  gap: 0.5rem;\n}\n.parvus__btn {\n  appearance: none;\n  background-color: var(--parvus-btn-background-color);\n  background-image: none;\n  border-radius: 0;\n  border: 0.0625rem solid transparent;\n  color: var(--parvus-btn-color);\n  cursor: pointer;\n  display: flex;\n  font: inherit;\n  padding: 0.3125rem;\n  position: relative;\n  touch-action: manipulation;\n  will-change: transform, opacity;\n  z-index: 7;\n}\n.parvus__btn:hover, .parvus__btn:focus-visible {\n  background-color: var(--parvus-btn-hover-background-color);\n  color: var(--parvus-btn-hover-color);\n}\n.parvus__btn--previous {\n  inset-inline-start: 0;\n  position: absolute;\n  inset-block-start: calc(50svh - 1rem);\n  transform: translateY(-50%);\n}\n.parvus__btn--next {\n  position: absolute;\n  inset-inline-end: 0;\n  inset-block-start: calc(50svh - 1rem);\n  transform: translateY(-50%);\n}\n.parvus__btn svg {\n  pointer-events: none;\n}\n.parvus__btn[aria-hidden=true] {\n  display: none;\n}\n.parvus__btn[aria-disabled=true] {\n  background-color: var(--parvus-btn-disabled-background-color);\n  color: var(--parvus-btn-disabled-color);\n}\n.parvus__counter {\n  position: relative;\n  z-index: 7;\n}\n.parvus__counter[aria-hidden=true] {\n  display: none;\n}\n@media screen and (prefers-reduced-motion: no-preference) {\n  .parvus__overlay, .parvus__counter, .parvus__btn, .parvus__caption, .parvus__copyright {\n    transition: transform var(--parvus-transition-duration) var(--parvus-transition-timing-function), opacity var(--parvus-transition-duration) var(--parvus-transition-timing-function);\n    will-change: transform, opacity;\n  }\n  .parvus__copyright {\n    transition-delay: var(--parvus-transition-duration);\n  }\n  .parvus--is-closing .parvus__copyright, .parvus--is-vertical-closing .parvus__copyright, .parvus--is-zooming .parvus__copyright {\n    transition-delay: 0s;\n    transition-duration: 0s;\n  }\n  .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 {\n    opacity: 0;\n  }\n  .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) {\n    transform: translateY(-100%);\n    opacity: 0;\n  }\n  .parvus--is-vertical-closing .parvus__btn--previous, .parvus--is-zooming .parvus__btn--previous {\n    transform: translate(-100%, -50%);\n    opacity: 0;\n  }\n  .parvus--is-vertical-closing .parvus__btn--next, .parvus--is-zooming .parvus__btn--next {\n    transform: translate(100%, -50%);\n    opacity: 0;\n  }\n  .parvus--is-vertical-closing .parvus__caption, .parvus--is-zooming .parvus__caption {\n    transform: translateY(100%);\n    opacity: 0;\n  }\n  .parvus--is-vertical-closing .parvus__copyright, .parvus--is-zooming .parvus__copyright {\n    opacity: 0;\n  }\n}\n\n@keyframes spin {\n  from {\n    transform: rotate(0deg);\n  }\n  to {\n    transform: rotate(360deg);\n  }\n}"
  },
  {
    "path": "dist/js/parvus.esm.js",
    "content": "/**\n * Parvus\n *\n * @author Benjamin de Oostfrees\n * @version 3.1.0\n * @url https://github.com/deoostfrees/parvus\n *\n * MIT license\n */\n\nconst BROWSER_WINDOW = window;\n\n/**\n * Get scrollbar width\n *\n * @return {Number} - The scrollbar width\n */\nconst getScrollbarWidth = () => {\n  return BROWSER_WINDOW.innerWidth - document.documentElement.clientWidth;\n};\nconst FOCUSABLE_ELEMENTS = ['a:not([inert]):not([tabindex^=\"-\"])', 'button:not([inert]):not([tabindex^=\"-\"]):not(:disabled)', '[tabindex]:not([inert]):not([tabindex^=\"-\"])'];\n\n/**\n * Get the focusable children of the given element\n *\n * @return {Array<Element>} - An array of focusable children\n */\nconst getFocusableChildren = targetEl => {\n  return Array.from(targetEl.querySelectorAll(FOCUSABLE_ELEMENTS.join(', '))).filter(child => child.offsetParent !== null);\n};\n\nvar en = {\n  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.',\n  lightboxLoadingIndicatorLabel: 'Image loading',\n  lightboxLoadingError: 'The requested image cannot be loaded.',\n  controlsLabel: 'Controls',\n  previousButtonLabel: 'Previous image',\n  nextButtonLabel: 'Next image',\n  closeButtonLabel: 'Close dialog window',\n  sliderLabel: 'Images',\n  slideLabel: 'Image'\n};\n\n/**\n * Default configuration options\n */\nconst DEFAULT_OPTIONS = {\n  selector: '.lightbox',\n  gallerySelector: null,\n  zoomIndicator: true,\n  captions: true,\n  captionsSelector: 'self',\n  captionsAttribute: 'data-caption',\n  copyright: true,\n  copyrightSelector: 'self',\n  copyrightAttribute: 'data-copyright',\n  docClose: true,\n  swipeClose: true,\n  simulateTouch: true,\n  threshold: 50,\n  hideScrollbar: true,\n  lightboxIndicatorIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" stroke=\"currentColor\"><path d=\"M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3\"/></svg>',\n  previousButtonIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1\" stroke=\"currentColor\"><path stroke=\"none\" d=\"M0 0h24v24H0z\"/><polyline points=\"15 6 9 12 15 18\" /></svg>',\n  nextButtonIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1\" stroke=\"currentColor\"><path stroke=\"none\" d=\"M0 0h24v24H0z\"/><polyline points=\"9 6 15 12 9 18\" /></svg>',\n  closeButtonIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1\" stroke=\"currentColor\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>',\n  l10n: en\n};\n\n/**\n * Merge default options with user-provided options\n *\n * @param {Object} userOptions - User-provided options\n * @returns {Object} - Merged options object\n */\nconst mergeOptions = userOptions => {\n  const MERGED_OPTIONS = {\n    ...DEFAULT_OPTIONS,\n    ...userOptions\n  };\n  if (userOptions && userOptions.l10n) {\n    MERGED_OPTIONS.l10n = {\n      ...DEFAULT_OPTIONS.l10n,\n      ...userOptions.l10n\n    };\n  }\n  return MERGED_OPTIONS;\n};\n\n/**\n * State management for Parvus\n *\n * Centralizes all mutable state variables\n */\nclass ParvusState {\n  constructor() {\n    // Group management\n    this.GROUP_ATTRIBUTES = {\n      triggerElements: [],\n      slider: null,\n      sliderElements: [],\n      contentElements: []\n    };\n    this.GROUPS = {};\n    this.groupIdCounter = 0;\n    this.newGroup = null;\n    this.activeGroup = null;\n    this.currentIndex = 0;\n\n    // Configuration\n    this.config = {};\n\n    // DOM elements\n    this.lightbox = null;\n    this.lightboxOverlay = null;\n    this.lightboxOverlayOpacity = 1;\n    this.toolbar = null;\n    this.toolbarLeft = null;\n    this.toolbarRight = null;\n    this.controls = null;\n    this.previousButton = null;\n    this.nextButton = null;\n    this.closeButton = null;\n    this.counter = null;\n\n    // Drag & interaction state\n    this.drag = {};\n    this.isDraggingX = false;\n    this.isDraggingY = false;\n    this.pointerDown = false;\n    this.activePointers = new Map();\n\n    // Zoom state\n    this.currentScale = 1;\n    this.isPinching = false;\n    this.isTap = false;\n    this.pinchStartDistance = 0;\n    this.lastPointersId = null;\n\n    // Offset & animation\n    this.offset = null;\n    this.offsetTmp = null;\n    this.resizeTicking = false;\n    this.isReducedMotion = true;\n  }\n\n  /**\n   * Clear drag state\n   */\n  clearDrag() {\n    this.drag = {\n      startX: 0,\n      endX: 0,\n      startY: 0,\n      endY: 0\n    };\n  }\n\n  /**\n   * Get the active group\n   *\n   * @returns {Object} The active group\n   */\n  getActiveGroup() {\n    return this.GROUPS[this.activeGroup];\n  }\n\n  /**\n   * Reset zoom state\n   */\n  resetZoomState() {\n    this.isPinching = false;\n    this.isTap = false;\n    this.currentScale = 1;\n    this.pinchStartDistance = 0;\n    this.lastPointersId = '';\n  }\n}\n\n/**\n * Event System Module\n *\n * Handles custom event dispatching and listeners\n */\n\n/**\n * Dispatch a custom event\n *\n * @param {HTMLElement} lightbox - The lightbox element\n * @param {String} type - The type of the event to dispatch\n * @returns {void}\n */\nconst dispatchCustomEvent = (lightbox, type) => {\n  const CUSTOM_EVENT = new CustomEvent(type, {\n    cancelable: true\n  });\n  lightbox.dispatchEvent(CUSTOM_EVENT);\n};\n\n/**\n * Bind a specific event listener\n *\n * @param {HTMLElement} lightbox - The lightbox element\n * @param {String} eventName - The name of the event to bind\n * @param {Function} callback - The callback function\n * @returns {void}\n */\nconst on = (lightbox, eventName, callback) => {\n  if (lightbox) {\n    lightbox.addEventListener(eventName, callback);\n  }\n};\n\n/**\n * Unbind a specific event listener\n *\n * @param {HTMLElement} lightbox - The lightbox element\n * @param {String} eventName - The name of the event to unbind\n * @param {Function} callback - The callback function\n * @returns {void}\n */\nconst off = (lightbox, eventName, callback) => {\n  if (lightbox) {\n    lightbox.removeEventListener(eventName, callback);\n  }\n};\n\n/**\n * Navigation Module\n *\n * Handles slide navigation and transitions\n */\n\n/**\n * Update offset\n *\n * @param {Object} state - The application state\n * @returns {void}\n */\nconst updateOffset = state => {\n  state.activeGroup = state.activeGroup !== null ? state.activeGroup : state.newGroup;\n  state.offset = -state.currentIndex * state.lightbox.offsetWidth;\n  state.GROUPS[state.activeGroup].slider.style.transform = `translate3d(${state.offset}px, 0, 0)`;\n  state.offsetTmp = state.offset;\n};\n\n/**\n * Load slide with the specified index\n *\n * @param {Object} state - The application state\n * @param {Number} index - The index of the slide to be loaded\n * @returns {void}\n */\nconst loadSlide = (state, index) => {\n  state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'false');\n};\n\n/**\n * Leave slide\n *\n * @param {Object} state - The application state\n * @param {Number} index - The index of the slide to leave\n * @returns {void}\n */\nconst leaveSlide = (state, index) => {\n  if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {\n    state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'true');\n  }\n};\n\n/**\n * Preload slide with the specified index\n *\n * @param {Object} state - The application state\n * @param {Function} createSlide - Create slide function\n * @param {Function} createImage - Create image function\n * @param {Function} loadImage - Load image function\n * @param {Number} index - The index of the slide to be preloaded\n * @returns {void}\n */\nconst preload = (state, createSlide, createImage, loadImage, index) => {\n  if (index < 0 || index >= state.GROUPS[state.activeGroup].triggerElements.length || state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {\n    return;\n  }\n  createSlide(state, index);\n  createImage(state, state.GROUPS[state.activeGroup].triggerElements[index], index, () => {\n    loadImage(state, index);\n  });\n};\n\n/**\n * Utils Module\n *\n * Utility functions\n */\n\n/**\n * Check prefers reduced motion\n *\n * @param {Object} state - The application state\n * @param {MediaQueryList} motionQuery - The media query list\n * @returns {void}\n */\nconst reducedMotionCheck = (state, motionQuery) => {\n  if (motionQuery.matches) {\n    state.isReducedMotion = true;\n  } else {\n    state.isReducedMotion = false;\n  }\n};\n\n/**\n * Retrieves or creates a group identifier for the given element\n *\n * @param {Object} state - The application state\n * @param {HTMLElement} el - DOM element to get or assign a group to\n * @returns {string} The group identifier associated with the element\n */\nconst getGroup = (state, el) => {\n  // Return existing group identifier if already assigned\n  if (el.dataset.group) {\n    return el.dataset.group;\n  }\n\n  // Generate new unique group identifier using counter\n  const EL_GROUP = `default-${state.groupIdCounter++}`;\n\n  // Assign the new group identifier to element's dataset\n  el.dataset.group = EL_GROUP;\n  return EL_GROUP;\n};\n\n/**\n * Plugin management for Parvus\n *\n * Provides a system for registering and managing plugins\n */\n\nclass PluginManager {\n  constructor() {\n    this.plugins = [];\n    this.hooks = {};\n    this.context = null;\n    this.isInitialized = false;\n  }\n\n  /**\n   * Register a plugin\n   *\n   * @param {Object} plugin - Plugin object with name and install function\n   * @param {Object} options - Plugin-specific options\n   */\n  register(plugin, options = {}) {\n    if (!plugin || typeof plugin.install !== 'function') {\n      throw new Error('Plugin must have an install function');\n    }\n    if (!plugin.name) {\n      throw new Error('Plugin must have a name');\n    }\n\n    // Check if plugin is already registered\n    const existingPlugin = this.plugins.find(p => p.name === plugin.name);\n    if (existingPlugin) {\n      console.warn(`Plugin \"${plugin.name}\" is already registered`);\n      return;\n    }\n    this.plugins.push({\n      plugin,\n      options\n    });\n\n    // If already initialized, install immediately\n    if (this.isInitialized && this.context) {\n      this.installPlugin(plugin, options);\n    }\n  }\n\n  /**\n   * Install a single plugin\n   *\n   * @param {Object} plugin - Plugin object\n   * @param {Object} options - Plugin options\n   */\n  installPlugin(plugin, options) {\n    try {\n      plugin.install(this.context, options);\n\n      // If lightbox already exists, execute afterInit hook for this plugin immediately\n      if (this.context && this.context.state && this.context.state.lightbox) {\n        this.executeHook('afterInit', {\n          state: this.context.state\n        });\n      }\n    } catch (error) {\n      console.error(`Failed to install plugin \"${plugin.name}\":`, error);\n    }\n  }\n\n  /**\n   * Install all registered plugins\n   *\n   * @param {Object} context - Parvus instance context\n   */\n  install(context) {\n    this.context = context;\n    this.isInitialized = true;\n    this.plugins.forEach(({\n      plugin,\n      options\n    }) => {\n      this.installPlugin(plugin, options);\n    });\n  }\n\n  /**\n   * Execute a hook\n   *\n   * @param {String} hookName - Name of the hook\n   * @param {*} data - Data to pass to hook callbacks\n   */\n  executeHook(hookName, data) {\n    const callbacks = this.hooks[hookName] || [];\n    callbacks.forEach(callback => {\n      try {\n        callback(data);\n      } catch (error) {\n        console.error(`Error in hook \"${hookName}\":`, error);\n      }\n    });\n  }\n\n  /**\n   * Register a hook callback\n   *\n   * @param {String} hookName - Name of the hook\n   * @param {Function} callback - Callback function\n   */\n  addHook(hookName, callback) {\n    if (!this.hooks[hookName]) {\n      this.hooks[hookName] = [];\n    }\n    this.hooks[hookName].push(callback);\n  }\n\n  /**\n   * Remove a hook callback\n   *\n   * @param {String} hookName - Name of the hook\n   * @param {Function} callback - Callback function to remove\n   */\n  removeHook(hookName, callback) {\n    if (!this.hooks[hookName]) return;\n    this.hooks[hookName] = this.hooks[hookName].filter(cb => cb !== callback);\n  }\n\n  /**\n   * Get all registered plugins\n   *\n   * @returns {Array} Array of plugin names\n   */\n  getPlugins() {\n    return this.plugins.map(p => p.plugin.name);\n  }\n}\n\n/**\n * UI Components Module\n *\n * Handles creation of lightbox, toolbar, slider and slides\n */\n\n/**\n * Create the lightbox\n *\n * @param {Object} state - The application state\n * @returns {void}\n */\nconst createLightbox = state => {\n  const {\n    config\n  } = state;\n\n  // Use DocumentFragment to batch DOM operations\n  const fragment = document.createDocumentFragment();\n\n  // Create the lightbox container\n  state.lightbox = document.createElement('dialog');\n  state.lightbox.setAttribute('role', 'dialog');\n  state.lightbox.setAttribute('aria-modal', 'true');\n  state.lightbox.setAttribute('aria-label', config.l10n.lightboxLabel);\n  state.lightbox.classList.add('parvus');\n\n  // Create the lightbox overlay container\n  state.lightboxOverlay = document.createElement('div');\n  state.lightboxOverlay.classList.add('parvus__overlay');\n\n  // Create the toolbar\n  state.toolbar = document.createElement('div');\n  state.toolbar.className = 'parvus__toolbar';\n\n  // Create the toolbar items\n  state.toolbarLeft = document.createElement('div');\n  state.toolbarRight = document.createElement('div');\n\n  // Create the controls\n  state.controls = document.createElement('div');\n  state.controls.className = 'parvus__controls';\n  state.controls.setAttribute('role', 'group');\n  state.controls.setAttribute('aria-label', config.l10n.controlsLabel);\n\n  // Create the close button\n  state.closeButton = document.createElement('button');\n  state.closeButton.className = 'parvus__btn parvus__btn--close';\n  state.closeButton.setAttribute('type', 'button');\n  state.closeButton.setAttribute('aria-label', config.l10n.closeButtonLabel);\n  state.closeButton.innerHTML = config.closeButtonIcon;\n\n  // Create the previous button\n  state.previousButton = document.createElement('button');\n  state.previousButton.className = 'parvus__btn parvus__btn--previous';\n  state.previousButton.setAttribute('type', 'button');\n  state.previousButton.setAttribute('aria-label', config.l10n.previousButtonLabel);\n  state.previousButton.innerHTML = config.previousButtonIcon;\n\n  // Create the next button\n  state.nextButton = document.createElement('button');\n  state.nextButton.className = 'parvus__btn parvus__btn--next';\n  state.nextButton.setAttribute('type', 'button');\n  state.nextButton.setAttribute('aria-label', config.l10n.nextButtonLabel);\n  state.nextButton.innerHTML = config.nextButtonIcon;\n\n  // Create the counter\n  state.counter = document.createElement('div');\n  state.counter.className = 'parvus__counter';\n\n  // Add the control buttons to the controls\n  state.controls.append(state.closeButton, state.previousButton, state.nextButton);\n\n  // Add the counter to the left toolbar item\n  state.toolbarLeft.appendChild(state.counter);\n\n  // Add the controls to the right toolbar item\n  state.toolbarRight.appendChild(state.controls);\n\n  // Add the toolbar items to the toolbar\n  state.toolbar.append(state.toolbarLeft, state.toolbarRight);\n\n  // Add the overlay and the toolbar to the lightbox\n  state.lightbox.append(state.lightboxOverlay, state.toolbar);\n  fragment.appendChild(state.lightbox);\n\n  // Add to document body\n  document.body.appendChild(fragment);\n};\n\n/**\n * Create a slider\n *\n * @param {Object} state - The application state\n * @returns {void}\n */\nconst createSlider = state => {\n  const SLIDER = document.createElement('div');\n  SLIDER.className = 'parvus__slider';\n\n  // Update the slider reference in GROUPS\n  state.GROUPS[state.activeGroup].slider = SLIDER;\n\n  // Add the slider to the lightbox container\n  state.lightbox.appendChild(SLIDER);\n};\n\n/**\n * Get next slide index\n *\n * @param {Object} state - The application state\n * @param {Number} currentIndex - Current slide index\n * @returns {number} Index of the next available slide or -1 if none found\n */\nconst getNextSlideIndex = (state, currentIndex) => {\n  const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;\n  const TOTAL_SLIDE_ELEMENTS = SLIDE_ELEMENTS.length;\n  for (let i = currentIndex + 1; i < TOTAL_SLIDE_ELEMENTS; i++) {\n    if (SLIDE_ELEMENTS[i] !== undefined) {\n      return i;\n    }\n  }\n  return -1;\n};\n\n/**\n * Get previous slide index\n *\n * @param {Object} state - The application state\n * @param {number} currentIndex - Current slide index\n * @returns {number} Index of the previous available slide or -1 if none found\n */\nconst getPreviousSlideIndex = (state, currentIndex) => {\n  const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;\n  for (let i = currentIndex - 1; i >= 0; i--) {\n    if (SLIDE_ELEMENTS[i] !== undefined) {\n      return i;\n    }\n  }\n  return -1;\n};\n\n/**\n * Create a slide\n *\n * @param {Object} state - The application state\n * @param {Number} index - The index of the slide\n * @returns {void}\n */\nconst createSlide = (state, index) => {\n  if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {\n    return;\n  }\n  const FRAGMENT = document.createDocumentFragment();\n  const SLIDE_ELEMENT = document.createElement('div');\n  const SLIDE_ELEMENT_CONTENT = document.createElement('div');\n  const GROUP = state.GROUPS[state.activeGroup];\n  const TOTAL_TRIGGER_ELEMENTS = GROUP.triggerElements.length;\n  SLIDE_ELEMENT.className = 'parvus__slide';\n  SLIDE_ELEMENT.style.cssText = `\n    position: absolute;\n    left: ${index * 100}%;\n  `;\n  SLIDE_ELEMENT.setAttribute('aria-hidden', 'true');\n\n  // Add accessibility attributes if gallery has multiple slides\n  if (TOTAL_TRIGGER_ELEMENTS > 1) {\n    SLIDE_ELEMENT.setAttribute('role', 'group');\n    SLIDE_ELEMENT.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`);\n  }\n  SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT);\n  FRAGMENT.appendChild(SLIDE_ELEMENT);\n  GROUP.sliderElements[index] = SLIDE_ELEMENT;\n\n  // Insert the slide element based on index position\n  if (index >= state.currentIndex) {\n    // Insert the slide element after the current slide\n    const NEXT_SLIDE_INDEX = getNextSlideIndex(state, index);\n    if (NEXT_SLIDE_INDEX !== -1) {\n      GROUP.sliderElements[NEXT_SLIDE_INDEX].before(SLIDE_ELEMENT);\n    } else {\n      GROUP.slider.appendChild(SLIDE_ELEMENT);\n    }\n  } else {\n    // Insert the slide element before the current slide\n    const PREVIOUS_SLIDE_INDEX = getPreviousSlideIndex(state, index);\n    if (PREVIOUS_SLIDE_INDEX !== -1) {\n      GROUP.sliderElements[PREVIOUS_SLIDE_INDEX].after(SLIDE_ELEMENT);\n    } else {\n      GROUP.slider.prepend(SLIDE_ELEMENT);\n    }\n  }\n};\n\n/**\n * Update counter\n *\n * @param {Object} state - The application state\n * @returns {void}\n */\nconst updateCounter = state => {\n  state.counter.textContent = `${state.currentIndex + 1}/${state.GROUPS[state.activeGroup].triggerElements.length}`;\n};\n\n/**\n * Update Attributes\n *\n * @param {Object} state - The application state\n * @returns {void}\n */\nconst updateAttributes = state => {\n  const TRIGGER_ELEMENTS = state.GROUPS[state.activeGroup].triggerElements;\n  const TOTAL_TRIGGER_ELEMENTS = TRIGGER_ELEMENTS.length;\n  const SLIDER = state.GROUPS[state.activeGroup].slider;\n  const SLIDER_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;\n  const IS_DRAGGABLE = SLIDER.classList.contains('parvus__slider--is-draggable');\n\n  // Add draggable class if necessary\n  if (state.config.simulateTouch && state.config.swipeClose && !IS_DRAGGABLE || state.config.simulateTouch && TOTAL_TRIGGER_ELEMENTS > 1 && !IS_DRAGGABLE) {\n    SLIDER.classList.add('parvus__slider--is-draggable');\n  } else {\n    SLIDER.classList.remove('parvus__slider--is-draggable');\n  }\n\n  // Add extra output for screen reader if there is more than one slide\n  if (TOTAL_TRIGGER_ELEMENTS > 1) {\n    SLIDER.setAttribute('role', 'region');\n    SLIDER.setAttribute('aria-roledescription', 'carousel');\n    SLIDER.setAttribute('aria-label', state.config.l10n.sliderLabel);\n    SLIDER_ELEMENTS.forEach((sliderElement, index) => {\n      sliderElement.setAttribute('role', 'group');\n      sliderElement.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`);\n    });\n  } else {\n    SLIDER.removeAttribute('role');\n    SLIDER.removeAttribute('aria-roledescription');\n    SLIDER.removeAttribute('aria-label');\n    SLIDER_ELEMENTS.forEach(sliderElement => {\n      sliderElement.removeAttribute('role');\n      sliderElement.removeAttribute('aria-label');\n    });\n  }\n\n  // Show or hide buttons\n  if (TOTAL_TRIGGER_ELEMENTS === 1) {\n    state.counter.setAttribute('aria-hidden', 'true');\n    state.previousButton.setAttribute('aria-hidden', 'true');\n    state.nextButton.setAttribute('aria-hidden', 'true');\n  } else {\n    state.counter.removeAttribute('aria-hidden');\n    state.previousButton.removeAttribute('aria-hidden');\n    state.nextButton.removeAttribute('aria-hidden');\n  }\n};\n\n/**\n * Update slider navigation status\n *\n * @param {Object} state - The application state\n * @returns {void}\n */\nconst updateSliderNavigationStatus = state => {\n  const {\n    triggerElements\n  } = state.GROUPS[state.activeGroup];\n  const TOTAL_TRIGGER_ELEMENTS = triggerElements.length;\n  if (TOTAL_TRIGGER_ELEMENTS <= 1) {\n    return;\n  }\n\n  // Determine navigation state\n  const FIRST_SLIDE = state.currentIndex === 0;\n  const LAST_SLIDE = state.currentIndex === TOTAL_TRIGGER_ELEMENTS - 1;\n\n  // Set previous button state\n  const PREV_DISABLED = FIRST_SLIDE ? 'true' : null;\n  if (state.previousButton.getAttribute('aria-disabled') === 'true' !== !!PREV_DISABLED) {\n    PREV_DISABLED ? state.previousButton.setAttribute('aria-disabled', 'true') : state.previousButton.removeAttribute('aria-disabled');\n  }\n\n  // Set next button state\n  const NEXT_DISABLED = LAST_SLIDE ? 'true' : null;\n  if (state.nextButton.getAttribute('aria-disabled') === 'true' !== !!NEXT_DISABLED) {\n    NEXT_DISABLED ? state.nextButton.setAttribute('aria-disabled', 'true') : state.nextButton.removeAttribute('aria-disabled');\n  }\n};\n\n/**\n * Add zoom indicator to element\n *\n * @param {HTMLElement} el - The element to add the zoom indicator to\n * @param {Object} config - Options object\n */\nconst addZoomIndicator = (el, config) => {\n  if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') === null) {\n    const LIGHTBOX_INDICATOR_ICON = document.createElement('div');\n    LIGHTBOX_INDICATOR_ICON.className = 'parvus-zoom__indicator';\n    LIGHTBOX_INDICATOR_ICON.innerHTML = config.lightboxIndicatorIcon;\n    el.appendChild(LIGHTBOX_INDICATOR_ICON);\n  }\n};\n\n/**\n * Remove zoom indicator for element\n *\n * @param {HTMLElement} el - The element to remove the zoom indicator to\n */\nconst removeZoomIndicator = el => {\n  if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') !== null) {\n    const LIGHTBOX_INDICATOR_ICON = el.querySelector('.parvus-zoom__indicator');\n    el.removeChild(LIGHTBOX_INDICATOR_ICON);\n  }\n};\n\n/**\n * Keyboard Event Handler Module\n *\n * Handles all keyboard interactions\n */\n\n\n/**\n * Create keyboard event handler\n *\n * @param {Object} state - The application state\n * @param {Object} actions - Actions object with navigation functions\n * @returns {Function} Keyboard event handler\n */\nconst createKeydownHandler = (state, actions) => {\n  return event => {\n    const FOCUSABLE_CHILDREN = getFocusableChildren(state.lightbox);\n    const FOCUSED_ITEM_INDEX = FOCUSABLE_CHILDREN.indexOf(document.activeElement);\n    const lastIndex = FOCUSABLE_CHILDREN.length - 1;\n    switch (event.code) {\n      case 'Tab':\n        {\n          // Use the TAB key to navigate backwards and forwards\n          if (event.shiftKey) {\n            // Navigate backwards\n            if (FOCUSED_ITEM_INDEX === 0) {\n              FOCUSABLE_CHILDREN[lastIndex].focus();\n              event.preventDefault();\n            }\n          } else {\n            // Navigate forwards\n            if (FOCUSED_ITEM_INDEX === lastIndex) {\n              FOCUSABLE_CHILDREN[0].focus();\n              event.preventDefault();\n            }\n          }\n          break;\n        }\n      case 'Escape':\n        {\n          // Close Parvus when the ESC key is pressed\n          actions.close();\n          event.preventDefault();\n          break;\n        }\n      case 'ArrowLeft':\n        {\n          // Show the previous slide when the PREV key is pressed\n          actions.previous();\n          event.preventDefault();\n          break;\n        }\n      case 'ArrowRight':\n        {\n          // Show the next slide when the NEXT key is pressed\n          actions.next();\n          event.preventDefault();\n          break;\n        }\n    }\n  };\n};\n\n/**\n * Pointer Event Handler Module\n *\n * Handles all pointer interactions (mouse, touch, pen)\n */\n\n/**\n * Create pointerdown event handler\n *\n * @param {Object} state - The application state\n * @returns {Function} Pointerdown event handler\n */\nconst createPointerdownHandler = state => {\n  return event => {\n    event.preventDefault();\n    event.stopPropagation();\n    state.isDraggingX = false;\n    state.isDraggingY = false;\n    state.pointerDown = true;\n    state.activePointers.set(event.pointerId, event);\n    state.drag.startX = event.pageX;\n    state.drag.startY = event.pageY;\n    state.drag.endX = event.pageX;\n    state.drag.endY = event.pageY;\n    const {\n      slider\n    } = state.GROUPS[state.activeGroup];\n    slider.classList.add('parvus__slider--is-dragging');\n    slider.style.willChange = 'transform';\n    state.isTap = state.activePointers.size === 1;\n    if (state.config.swipeClose) {\n      state.lightboxOverlayOpacity = getComputedStyle(state.lightboxOverlay).opacity;\n    }\n  };\n};\n\n/**\n * Create pointermove event handler\n *\n * @param {Object} state - The application state\n * @param {Function} pinchZoom - Pinch zoom function\n * @param {Function} doSwipe - Swipe function\n * @returns {Function} Pointermove event handler\n */\nconst createPointermoveHandler = (state, pinchZoom, doSwipe) => {\n  return event => {\n    event.preventDefault();\n    if (!state.pointerDown) {\n      return;\n    }\n    const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex];\n\n    // Update pointer position\n    state.activePointers.set(event.pointerId, event);\n\n    // Zoom\n    if (CURRENT_IMAGE && CURRENT_IMAGE.tagName === 'IMG') {\n      if (state.activePointers.size === 2) {\n        pinchZoom(CURRENT_IMAGE);\n        return;\n      }\n      if (state.currentScale > 1) {\n        return;\n      }\n    }\n    state.drag.endX = event.pageX;\n    state.drag.endY = event.pageY;\n    doSwipe();\n  };\n};\n\n/**\n * Create pointerup event handler\n *\n * @param {Object} state - The application state\n * @param {Function} resetZoom - Reset zoom function\n * @param {Function} updateAfterDrag - Update after drag function\n * @returns {Function} Pointerup event handler\n */\nconst createPointerupHandler = (state, resetZoom, updateAfterDrag) => {\n  return event => {\n    event.stopPropagation();\n    const {\n      slider\n    } = state.GROUPS[state.activeGroup];\n    state.activePointers.delete(event.pointerId);\n    if (state.activePointers.size > 0) {\n      return;\n    }\n    state.pointerDown = false;\n    const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex];\n\n    // Reset zoom state by one tap\n    const MOVEMENT_X = Math.abs(state.drag.endX - state.drag.startX);\n    const MOVEMENT_Y = Math.abs(state.drag.endY - state.drag.startY);\n    const IS_TAP = MOVEMENT_X < 8 && MOVEMENT_Y < 8 && !state.isDraggingX && !state.isDraggingY && state.isTap;\n    slider.classList.remove('parvus__slider--is-dragging');\n    slider.style.willChange = '';\n    if (state.currentScale > 1) {\n      if (IS_TAP) {\n        resetZoom(CURRENT_IMAGE);\n      } else {\n        CURRENT_IMAGE.style.transform = `\n          scale(${state.currentScale})\n        `;\n      }\n    } else {\n      if (state.isPinching) {\n        resetZoom(CURRENT_IMAGE);\n      }\n      if (state.drag.endX || state.drag.endY) {\n        updateAfterDrag();\n      }\n    }\n    state.clearDrag();\n  };\n};\n\n/**\n * Create click event handler\n *\n * @param {Object} state - The application state\n * @param {Object} actions - Actions object with navigation functions\n * @returns {Function} Click event handler\n */\nconst createClickHandler = (state, actions) => {\n  return event => {\n    const {\n      target\n    } = event;\n    if (target === state.previousButton) {\n      actions.previous();\n    } else if (target === state.nextButton) {\n      actions.next();\n    } else if (target === state.closeButton || state.config.docClose && !state.isDraggingY && !state.isDraggingX && target.classList.contains('parvus__slide')) {\n      actions.close();\n    }\n    event.stopPropagation();\n  };\n};\n\n/**\n * Gesture Handler Module\n *\n * Handles gestures like pinch-to-zoom and swipe\n */\n\n/**\n * Reset image zoom\n *\n * @param {Object} state - The application state\n * @param {HTMLImageElement} currentImg - The image\n * @returns {void}\n */\nconst resetZoom = (state, currentImg) => {\n  currentImg.style.transition = 'transform 0.3s ease';\n  currentImg.style.transform = '';\n  setTimeout(() => {\n    currentImg.style.transition = '';\n    currentImg.style.transformOrigin = '';\n  }, 300);\n  state.resetZoomState();\n  state.lightbox.classList.remove('parvus--is-zooming');\n};\n\n/**\n * Pinch zoom gesture\n *\n * @param {Object} state - The application state\n * @param {HTMLImageElement} currentImg - The image to zoom\n * @returns {void}\n */\nconst pinchZoom = (state, currentImg) => {\n  // Determine current finger positions\n  const POINTS = Array.from(state.activePointers.values());\n\n  // Calculate current distance between fingers\n  const CURRENT_DISTANCE = Math.hypot(POINTS[1].clientX - POINTS[0].clientX, POINTS[1].clientY - POINTS[0].clientY);\n\n  // Calculate the midpoint between the two points\n  const MIDPOINT_X = (POINTS[0].clientX + POINTS[1].clientX) / 2;\n  const MIDPOINT_Y = (POINTS[0].clientY + POINTS[1].clientY) / 2;\n\n  // Convert midpoint to relative position within the image\n  const IMG_RECT = currentImg.getBoundingClientRect();\n  const RELATIVE_X = (MIDPOINT_X - IMG_RECT.left) / IMG_RECT.width;\n  const RELATIVE_Y = (MIDPOINT_Y - IMG_RECT.top) / IMG_RECT.height;\n\n  // When pinch gesture is about to start or the finger IDs have changed\n  // Use a unique ID based on the pointer IDs to recognize changes\n  const CURRENT_POINTERS_ID = POINTS.map(p => p.pointerId).sort().join('-');\n  const IS_NEW_POINTER_COMBINATION = state.lastPointersId !== CURRENT_POINTERS_ID;\n  if (!state.isPinching || IS_NEW_POINTER_COMBINATION) {\n    state.isPinching = true;\n    state.lastPointersId = CURRENT_POINTERS_ID;\n\n    // Save the start distance and current scaling as a basis\n    state.pinchStartDistance = CURRENT_DISTANCE / state.currentScale;\n\n    // Store initial pinch position for this gesture\n    if (!currentImg.style.transformOrigin && state.currentScale === 1 || state.currentScale === 1 && IS_NEW_POINTER_COMBINATION) {\n      // Set the transform origin to the pinch midpoint\n      currentImg.style.transformOrigin = `${RELATIVE_X * 100}% ${RELATIVE_Y * 100}%`;\n    }\n    state.lightbox.classList.add('parvus--is-zooming');\n  }\n\n  // Calculate scaling factor based on distance change\n  const SCALE_FACTOR = CURRENT_DISTANCE / state.pinchStartDistance;\n\n  // Limit scaling to 1 - 3\n  state.currentScale = Math.min(Math.max(1, SCALE_FACTOR), 3);\n  currentImg.style.willChange = 'transform';\n  currentImg.style.transform = `scale(${state.currentScale})`;\n};\n\n/**\n * Determine the swipe direction (horizontal or vertical)\n *\n * @param {Object} state - The application state\n * @returns {void}\n */\nconst doSwipe = state => {\n  const MOVEMENT_THRESHOLD = 1.5;\n  const MAX_OPACITY_DISTANCE = 100;\n  const DIRECTION_BIAS = 1.15;\n  const {\n    startX,\n    endX,\n    startY,\n    endY\n  } = state.drag;\n  const MOVEMENT_X = startX - endX;\n  const MOVEMENT_Y = endY - startY;\n  const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X);\n  const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y);\n  const GROUP = state.GROUPS[state.activeGroup];\n  const SLIDER = GROUP.slider;\n  const TOTAL_SLIDES = GROUP.triggerElements.length;\n  const handleHorizontalSwipe = (movementX, distance) => {\n    const IS_FIRST_SLIDE = state.currentIndex === 0;\n    const IS_LAST_SLIDE = state.currentIndex === TOTAL_SLIDES - 1;\n    const IS_LEFT_SWIPE = movementX > 0;\n    const IS_RIGHT_SWIPE = movementX < 0;\n    if (IS_FIRST_SLIDE && IS_RIGHT_SWIPE || IS_LAST_SLIDE && IS_LEFT_SWIPE) {\n      const DAMPING_FACTOR = 1 / (1 + Math.pow(distance / 100, 0.15));\n      const REDUCED_MOVEMENT = movementX * DAMPING_FACTOR;\n      SLIDER.style.transform = `\n        translate3d(${state.offsetTmp - Math.round(REDUCED_MOVEMENT)}px, 0, 0)\n      `;\n    } else {\n      SLIDER.style.transform = `\n        translate3d(${state.offsetTmp - Math.round(movementX)}px, 0, 0)\n      `;\n    }\n  };\n  const handleVerticalSwipe = (movementY, distance) => {\n    if (!state.isReducedMotion && distance <= 100) {\n      const NEW_OVERLAY_OPACITY = Math.max(0, state.lightboxOverlayOpacity - distance / MAX_OPACITY_DISTANCE);\n      state.lightboxOverlay.style.opacity = NEW_OVERLAY_OPACITY;\n    }\n    state.lightbox.classList.add('parvus--is-vertical-closing');\n    SLIDER.style.transform = `\n      translate3d(${state.offsetTmp}px, ${Math.round(movementY)}px, 0)\n    `;\n  };\n  if (state.isDraggingX || state.isDraggingY) {\n    if (state.isDraggingX) {\n      handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE);\n    } else if (state.isDraggingY) {\n      handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE);\n    }\n    return;\n  }\n\n  // Direction detection based on the relative ratio of movements\n  if (MOVEMENT_X_DISTANCE > MOVEMENT_THRESHOLD || MOVEMENT_Y_DISTANCE > MOVEMENT_THRESHOLD) {\n    // Horizontal swipe if X-movement is stronger than Y-movement * DIRECTION_BIAS\n    if (MOVEMENT_X_DISTANCE > MOVEMENT_Y_DISTANCE * DIRECTION_BIAS && TOTAL_SLIDES > 1) {\n      state.isDraggingX = true;\n      state.isDraggingY = false;\n      handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE);\n    } else if (MOVEMENT_Y_DISTANCE > MOVEMENT_X_DISTANCE * DIRECTION_BIAS && state.config.swipeClose) {\n      // Vertical swipe if Y-movement is stronger than X-movement * DIRECTION_BIAS\n      state.isDraggingX = false;\n      state.isDraggingY = true;\n      handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE);\n    }\n  }\n};\n\n/**\n * Recalculate drag/swipe event after pointerup\n *\n * @param {Object} state - The application state\n * @param {Object} actions - Navigation actions\n * @returns {void}\n */\nconst updateAfterDrag = (state, actions) => {\n  const {\n    startX,\n    startY,\n    endX,\n    endY\n  } = state.drag;\n  const MOVEMENT_X = endX - startX;\n  const MOVEMENT_Y = endY - startY;\n  const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X);\n  const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y);\n  const {\n    triggerElements\n  } = state.GROUPS[state.activeGroup];\n  const TOTAL_TRIGGER_ELEMENTS = triggerElements.length;\n  if (state.isDraggingX) {\n    const IS_RIGHT_SWIPE = MOVEMENT_X > 0;\n    if (MOVEMENT_X_DISTANCE >= state.config.threshold) {\n      if (IS_RIGHT_SWIPE && state.currentIndex > 0) {\n        actions.previous();\n      } else if (!IS_RIGHT_SWIPE && state.currentIndex < TOTAL_TRIGGER_ELEMENTS - 1) {\n        actions.next();\n      }\n    }\n    actions.updateOffset();\n  } else if (state.isDraggingY) {\n    if (MOVEMENT_Y_DISTANCE >= state.config.threshold && state.config.swipeClose) {\n      actions.close();\n    } else {\n      state.lightbox.classList.remove('parvus--is-vertical-closing');\n      actions.updateOffset();\n    }\n    state.lightboxOverlay.style.opacity = '';\n  } else {\n    actions.updateOffset();\n  }\n};\n\n/**\n * Image Handler Module\n *\n * Handles image loading, captions, and dimensions\n */\n\n/**\n * Add caption to the container element\n *\n * @param {Object} config - Configuration object\n * @param {HTMLElement} containerEl - The container element to which the caption will be added\n * @param {HTMLElement} imageEl - The image the caption is linked to\n * @param {HTMLElement} el - The trigger element associated with the caption\n * @param {Number} index - The index of the caption\n * @returns {void}\n */\nconst addCaption = (config, containerEl, imageEl, el, index) => {\n  const getCaptionData = triggerEl => {\n    const {\n      captionsAttribute,\n      captionsSelector,\n      captionsIdAttribute = 'data-caption-id'\n    } = config;\n\n    // Check for an ID reference on the trigger element\n    // This allows the caption to be anywhere on the page\n    const CAPTION_ID = triggerEl.getAttribute(captionsIdAttribute);\n    if (CAPTION_ID) {\n      const CAPTION_EL = document.getElementById(CAPTION_ID);\n      if (CAPTION_EL) {\n        return CAPTION_EL.innerHTML;\n      }\n    }\n\n    // Check for a direct caption attribute on the trigger element\n    const DIRECT_CAPTION = triggerEl.getAttribute(captionsAttribute);\n    if (DIRECT_CAPTION) {\n      return DIRECT_CAPTION;\n    }\n\n    // Query for a selector inside the trigger element\n    if (captionsSelector !== 'self') {\n      const CAPTION_EL = triggerEl.querySelector(captionsSelector);\n      if (CAPTION_EL) {\n        // Prefer a direct attribute on the found element, otherwise use its content\n        return CAPTION_EL.getAttribute(captionsAttribute) || CAPTION_EL.innerHTML;\n      }\n    }\n    return null;\n  };\n  const CAPTION_DATA = getCaptionData(el);\n  if (CAPTION_DATA) {\n    const CAPTION_CONTAINER = document.createElement('div');\n    const CAPTION_ID = `parvus__caption-${index}`;\n    CAPTION_CONTAINER.className = 'parvus__caption';\n    CAPTION_CONTAINER.id = CAPTION_ID;\n    CAPTION_CONTAINER.innerHTML = `<p>${CAPTION_DATA}</p>`;\n    containerEl.appendChild(CAPTION_CONTAINER);\n    imageEl.setAttribute('aria-describedby', CAPTION_ID);\n  }\n};\n\n/**\n * Add copyright to the image container element\n *\n * @param {Object} config - Configuration object\n * @param {HTMLElement} imageContainer - The image container element (parvus__content) to which the copyright will be added\n * @param {HTMLElement} imageEl - The image the copyright is linked to\n * @param {HTMLElement} el - The trigger element associated with the copyright\n * @param {Number} index - The index of the copyright\n * @returns {void}\n */\nconst addCopyright = (config, imageContainer, imageEl, el, index) => {\n  const getCopyrightData = triggerEl => {\n    const {\n      copyrightAttribute,\n      copyrightSelector,\n      copyrightIdAttribute = 'data-copyright-id'\n    } = config;\n\n    // Check for an ID reference on the trigger element\n    // This allows the copyright to be anywhere on the page\n    const COPYRIGHT_ID = triggerEl.getAttribute(copyrightIdAttribute);\n    if (COPYRIGHT_ID) {\n      const COPYRIGHT_EL = document.getElementById(COPYRIGHT_ID);\n      if (COPYRIGHT_EL) {\n        return COPYRIGHT_EL.innerHTML;\n      }\n    }\n\n    // Check for a direct copyright attribute on the trigger element\n    const DIRECT_COPYRIGHT = triggerEl.getAttribute(copyrightAttribute);\n    if (DIRECT_COPYRIGHT) {\n      return DIRECT_COPYRIGHT;\n    }\n\n    // Query for a selector inside the trigger element\n    if (copyrightSelector !== 'self') {\n      const COPYRIGHT_EL = triggerEl.querySelector(copyrightSelector);\n      if (COPYRIGHT_EL) {\n        // Prefer a direct attribute on the found element, otherwise use its content\n        return COPYRIGHT_EL.getAttribute(copyrightAttribute) || COPYRIGHT_EL.innerHTML;\n      }\n    }\n    return null;\n  };\n  const COPYRIGHT_DATA = getCopyrightData(el);\n  if (COPYRIGHT_DATA) {\n    const COPYRIGHT_CONTAINER = document.createElement('div');\n    const COPYRIGHT_ID = `parvus__copyright-${index}`;\n    COPYRIGHT_CONTAINER.className = 'parvus__copyright';\n    COPYRIGHT_CONTAINER.id = COPYRIGHT_ID;\n    COPYRIGHT_CONTAINER.innerHTML = `<small>${COPYRIGHT_DATA}</small>`;\n    imageContainer.appendChild(COPYRIGHT_CONTAINER);\n\n    // If image already has aria-describedby (from caption), append copyright ID\n    const existingAriaDescribedby = imageEl.getAttribute('aria-describedby');\n    if (existingAriaDescribedby) {\n      imageEl.setAttribute('aria-describedby', `${existingAriaDescribedby} ${COPYRIGHT_ID}`);\n    } else {\n      imageEl.setAttribute('aria-describedby', COPYRIGHT_ID);\n    }\n  }\n};\n\n/**\n * Create image\n *\n * @param {Object} state - The application state\n * @param {HTMLElement} el - The trigger element\n * @param {Number} index - The index\n * @param {Function} callback - Callback function\n * @returns {void}\n */\nconst createImage = (state, el, index, callback) => {\n  const {\n    contentElements,\n    sliderElements\n  } = state.GROUPS[state.activeGroup];\n  if (contentElements[index] !== undefined) {\n    if (callback && typeof callback === 'function') {\n      callback();\n    }\n    return;\n  }\n  const CONTENT_CONTAINER_EL = sliderElements[index].querySelector('div');\n  const IMAGE = new Image();\n  const IMAGE_CONTAINER = document.createElement('div');\n  const THUMBNAIL = el.querySelector('img');\n  const LOADING_INDICATOR = document.createElement('div');\n  IMAGE_CONTAINER.className = 'parvus__content';\n\n  // Create loading indicator\n  LOADING_INDICATOR.className = 'parvus__loader';\n  LOADING_INDICATOR.setAttribute('role', 'progressbar');\n  LOADING_INDICATOR.setAttribute('aria-label', state.config.l10n.lightboxLoadingIndicatorLabel);\n\n  // Add loading indicator to content container\n  CONTENT_CONTAINER_EL.appendChild(LOADING_INDICATOR);\n  const checkImagePromise = new Promise((resolve, reject) => {\n    IMAGE.onload = () => resolve(IMAGE);\n    IMAGE.onerror = error => reject(error);\n  });\n  checkImagePromise.then(loadedImage => {\n    loadedImage.style.opacity = 0;\n    IMAGE_CONTAINER.appendChild(loadedImage);\n\n    // Add copyright if available (inside IMAGE_CONTAINER)\n    if (state.config.copyright) {\n      addCopyright(state.config, IMAGE_CONTAINER, IMAGE, el, index);\n    }\n    CONTENT_CONTAINER_EL.appendChild(IMAGE_CONTAINER);\n\n    // Add caption if available\n    if (state.config.captions) {\n      addCaption(state.config, CONTENT_CONTAINER_EL, IMAGE, el, index);\n    }\n    contentElements[index] = loadedImage;\n\n    // Set image width and height\n    loadedImage.setAttribute('width', loadedImage.naturalWidth);\n    loadedImage.setAttribute('height', loadedImage.naturalHeight);\n\n    // Set image dimension\n    setImageDimension(sliderElements[index], loadedImage);\n  }).catch(() => {\n    const ERROR_CONTAINER = document.createElement('div');\n    ERROR_CONTAINER.classList.add('parvus__content');\n    ERROR_CONTAINER.classList.add('parvus__content--error');\n    ERROR_CONTAINER.textContent = state.config.l10n.lightboxLoadingError;\n    CONTENT_CONTAINER_EL.appendChild(ERROR_CONTAINER);\n    contentElements[index] = ERROR_CONTAINER;\n  }).finally(() => {\n    CONTENT_CONTAINER_EL.removeChild(LOADING_INDICATOR);\n    if (callback && typeof callback === 'function') {\n      callback();\n    }\n  });\n\n  // Add `sizes` attribute\n  if (el.hasAttribute('data-sizes') && el.getAttribute('data-sizes') !== '') {\n    IMAGE.setAttribute('sizes', el.getAttribute('data-sizes'));\n  }\n\n  // Add `srcset` attribute\n  if (el.hasAttribute('data-srcset') && el.getAttribute('data-srcset') !== '') {\n    IMAGE.setAttribute('srcset', el.getAttribute('data-srcset'));\n  }\n\n  // Add `src` attribute\n  if (el.tagName === 'A') {\n    IMAGE.setAttribute('src', el.href);\n  } else {\n    IMAGE.setAttribute('src', el.getAttribute('data-target'));\n  }\n\n  // `alt` attribute\n  if (THUMBNAIL && THUMBNAIL.hasAttribute('alt') && THUMBNAIL.getAttribute('alt') !== '') {\n    IMAGE.alt = THUMBNAIL.alt;\n  } else if (el.hasAttribute('data-alt') && el.getAttribute('data-alt') !== '') {\n    IMAGE.alt = el.getAttribute('data-alt');\n  } else {\n    IMAGE.alt = '';\n  }\n};\n\n/**\n * Load Image\n *\n * @param {Object} state - The application state\n * @param {Number} index - The index of the image to load\n * @param {Boolean} animate - Whether to animate the image\n * @returns {void}\n */\nconst loadImage = (state, index, animate) => {\n  const IMAGE = state.GROUPS[state.activeGroup].contentElements[index];\n  if (IMAGE && IMAGE.tagName === 'IMG') {\n    const THUMBNAIL = state.GROUPS[state.activeGroup].triggerElements[index];\n    if (animate && document.startViewTransition) {\n      THUMBNAIL.style.viewTransitionName = 'lightboximage';\n      const transition = document.startViewTransition(() => {\n        IMAGE.style.opacity = '';\n        THUMBNAIL.style.viewTransitionName = null;\n        IMAGE.style.viewTransitionName = 'lightboximage';\n      });\n      transition.finished.finally(() => {\n        IMAGE.style.viewTransitionName = null;\n      });\n    } else {\n      IMAGE.style.opacity = '';\n    }\n  } else {\n    IMAGE.style.opacity = '';\n  }\n};\n\n/**\n * Set image dimension\n *\n * @param {HTMLElement} slideEl - The slide element\n * @param {HTMLElement} contentEl - The content element\n * @returns {void}\n */\nconst setImageDimension = (slideEl, contentEl) => {\n  if (contentEl.tagName !== 'IMG') {\n    return;\n  }\n  const SRC_HEIGHT = contentEl.getAttribute('height');\n  const SRC_WIDTH = contentEl.getAttribute('width');\n  if (!SRC_HEIGHT || !SRC_WIDTH) {\n    return;\n  }\n  const SLIDE_EL_STYLES = getComputedStyle(slideEl);\n  const HORIZONTAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingLeft) + parseFloat(SLIDE_EL_STYLES.paddingRight);\n  const VERTICAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingTop) + parseFloat(SLIDE_EL_STYLES.paddingBottom);\n  const CAPTION_EL = slideEl.querySelector('.parvus__caption');\n  const CAPTION_HEIGHT = CAPTION_EL ? CAPTION_EL.getBoundingClientRect().height : 0;\n  const MAX_WIDTH = slideEl.offsetWidth - HORIZONTAL_PADDING;\n  const MAX_HEIGHT = slideEl.offsetHeight - VERTICAL_PADDING - CAPTION_HEIGHT;\n  const RATIO = Math.min(MAX_WIDTH / SRC_WIDTH || 0, MAX_HEIGHT / SRC_HEIGHT || 0);\n  const NEW_WIDTH = SRC_WIDTH * RATIO;\n  const NEW_HEIGHT = SRC_HEIGHT * RATIO;\n  const USE_ORIGINAL_SIZE = SRC_WIDTH <= MAX_WIDTH && SRC_HEIGHT <= MAX_HEIGHT;\n  contentEl.style.width = USE_ORIGINAL_SIZE ? '' : `${NEW_WIDTH}px`;\n  contentEl.style.height = USE_ORIGINAL_SIZE ? '' : `${NEW_HEIGHT}px`;\n};\n\n/**\n * Create resize handler\n *\n * @param {Object} state - The application state\n * @param {Function} updateOffset - Update offset function\n * @returns {Function} Resize event handler\n */\nconst createResizeHandler = (state, updateOffset) => {\n  return () => {\n    if (!state.resizeTicking) {\n      state.resizeTicking = true;\n      window.requestAnimationFrame(() => {\n        state.GROUPS[state.activeGroup].sliderElements.forEach((slide, index) => {\n          setImageDimension(slide, state.GROUPS[state.activeGroup].contentElements[index]);\n        });\n        updateOffset();\n        state.resizeTicking = false;\n      });\n    }\n  };\n};\n\n// Helper modules\n\n/**\n * Parvus Lightbox\n *\n * @param {Object} userOptions - User configuration options\n * @returns {Object} Parvus instance\n */\nfunction Parvus(userOptions) {\n  const BROWSER_WINDOW = window;\n  const STATE = new ParvusState();\n  const MOTIONQUERY = BROWSER_WINDOW.matchMedia('(prefers-reduced-motion)');\n  const PLUGIN_MANAGER = new PluginManager();\n\n  // Event handlers will be created after actions are defined\n  let keydownHandler, clickHandler, pointerdownHandler, pointermoveHandler, pointerupHandler, resizeHandler;\n\n  /**\n   * Click event handler to trigger Parvus\n   *\n   * @param {Event} event - The click event object\n   */\n  const triggerParvus = function triggerParvus(event) {\n    event.preventDefault();\n    open(this);\n  };\n\n  /**\n   * Add an element\n   *\n   * @param {HTMLElement} el - The element to be added\n   */\n  const add = el => {\n    // Check element type and attributes\n    const IS_VALID_LINK = el.tagName === 'A' && el.hasAttribute('href');\n    const IS_VALID_BUTTON = el.tagName === 'BUTTON' && el.hasAttribute('data-target');\n    if (!IS_VALID_LINK && !IS_VALID_BUTTON) {\n      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.');\n    }\n\n    // Check if the lightbox already exists\n    if (!STATE.lightbox) {\n      createLightbox(STATE);\n\n      // Execute afterInit hook when lightbox is first created\n      PLUGIN_MANAGER.executeHook('afterInit', {\n        state: STATE\n      });\n    }\n    STATE.newGroup = getGroup(STATE, el);\n    if (!STATE.GROUPS[STATE.newGroup]) {\n      STATE.GROUPS[STATE.newGroup] = structuredClone(STATE.GROUP_ATTRIBUTES);\n    }\n    if (STATE.GROUPS[STATE.newGroup].triggerElements.includes(el)) {\n      throw new Error('Ups, element already added.');\n    }\n    STATE.GROUPS[STATE.newGroup].triggerElements.push(el);\n    if (STATE.config.zoomIndicator) {\n      addZoomIndicator(el, STATE.config);\n    }\n    el.classList.add('parvus-trigger');\n    el.addEventListener('click', triggerParvus);\n    if (isOpen() && STATE.newGroup === STATE.activeGroup) {\n      const EL_INDEX = STATE.GROUPS[STATE.newGroup].triggerElements.indexOf(el);\n      createSlide(STATE, EL_INDEX);\n      createImage(STATE, el, EL_INDEX, () => {\n        loadImage(STATE, EL_INDEX);\n      });\n      updateAttributes(STATE);\n      updateSliderNavigationStatus(STATE);\n      updateCounter(STATE);\n    }\n  };\n\n  /**\n   * Remove an element\n   *\n   * @param {HTMLElement} el - The element to be removed\n   */\n  const remove = el => {\n    if (!el || !el.hasAttribute('data-group')) {\n      return;\n    }\n    const EL_GROUP = getGroup(STATE, el);\n    const GROUP = STATE.GROUPS[EL_GROUP];\n\n    // Check if element exists\n    if (!GROUP) {\n      return;\n    }\n    const EL_INDEX = GROUP.triggerElements.indexOf(el);\n    if (EL_INDEX === -1) {\n      return;\n    }\n    const IS_CURRENT_EL = isOpen() && EL_GROUP === STATE.activeGroup && EL_INDEX === STATE.currentIndex;\n\n    // Remove group data\n    if (GROUP.contentElements[EL_INDEX]) {\n      const content = GROUP.contentElements[EL_INDEX];\n      if (content.tagName === 'IMG') {\n        content.src = '';\n        content.srcset = '';\n      }\n    }\n\n    // Remove DOM element\n    const sliderElement = GROUP.sliderElements[EL_INDEX];\n    if (sliderElement && sliderElement.parentNode) {\n      sliderElement.parentNode.removeChild(sliderElement);\n    }\n\n    // Remove all array elements\n    GROUP.triggerElements.splice(EL_INDEX, 1);\n    GROUP.sliderElements.splice(EL_INDEX, 1);\n    GROUP.contentElements.splice(EL_INDEX, 1);\n    if (STATE.config.zoomIndicator) {\n      removeZoomIndicator(el);\n    }\n    if (isOpen() && EL_GROUP === STATE.activeGroup) {\n      if (IS_CURRENT_EL) {\n        if (GROUP.triggerElements.length === 0) {\n          close();\n        } else if (STATE.currentIndex >= GROUP.triggerElements.length) {\n          select(GROUP.triggerElements.length - 1);\n        } else {\n          updateAttributes(STATE);\n          updateSliderNavigationStatus(STATE);\n          updateCounter(STATE);\n        }\n      } else if (EL_INDEX < STATE.currentIndex) {\n        STATE.currentIndex--;\n        updateAttributes(STATE);\n        updateSliderNavigationStatus(STATE);\n        updateCounter(STATE);\n      } else {\n        updateAttributes(STATE);\n        updateSliderNavigationStatus(STATE);\n        updateCounter(STATE);\n      }\n    }\n\n    // Unbind click event handler\n    el.removeEventListener('click', triggerParvus);\n    el.classList.remove('parvus-trigger');\n  };\n\n  /**\n   * Open Parvus\n   *\n   * @param {HTMLElement} el\n   */\n  const open = el => {\n    if (!STATE.lightbox || !el || !el.classList.contains('parvus-trigger') || isOpen()) {\n      return;\n    }\n    STATE.activeGroup = getGroup(STATE, el);\n    const GROUP = STATE.GROUPS[STATE.activeGroup];\n    const EL_INDEX = GROUP.triggerElements.indexOf(el);\n    if (EL_INDEX === -1) {\n      throw new Error('Ups, element not found in group.');\n    }\n    STATE.currentIndex = EL_INDEX;\n    history.pushState({\n      parvus: 'close'\n    }, 'Image', window.location.href);\n    bindEvents();\n    if (STATE.config.hideScrollbar) {\n      document.body.style.marginInlineEnd = `${getScrollbarWidth()}px`;\n      document.body.style.overflow = 'hidden';\n    }\n    STATE.lightbox.classList.add('parvus--is-opening');\n    STATE.lightbox.showModal();\n    createSlider(STATE);\n    createSlide(STATE, STATE.currentIndex);\n    updateOffset(STATE);\n    updateAttributes(STATE);\n    updateSliderNavigationStatus(STATE);\n    updateCounter(STATE);\n    loadSlide(STATE, STATE.currentIndex);\n    createImage(STATE, el, STATE.currentIndex, () => {\n      loadImage(STATE, STATE.currentIndex, true);\n      STATE.lightbox.classList.remove('parvus--is-opening');\n      GROUP.slider.classList.add('parvus__slider--animate');\n    });\n    preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex + 1);\n    preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex - 1);\n\n    // Execute afterOpen hook\n    PLUGIN_MANAGER.executeHook('afterOpen', {\n      element: el,\n      state: STATE\n    });\n\n    // Create and dispatch a new event\n    dispatchCustomEvent(STATE.lightbox, 'open');\n  };\n\n  /**\n   * Close Parvus\n   */\n  const close = () => {\n    if (!isOpen()) {\n      return;\n    }\n    const IMAGE = STATE.GROUPS[STATE.activeGroup].contentElements[STATE.currentIndex];\n    const THUMBNAIL = STATE.GROUPS[STATE.activeGroup].triggerElements[STATE.currentIndex];\n    unbindEvents();\n    STATE.clearDrag();\n    if (history.state?.parvus === 'close') {\n      history.back();\n    }\n    STATE.lightbox.classList.add('parvus--is-closing');\n    const transitionendHandler = () => {\n      // Reset the image zoom (if ESC was pressed or went back in the browser history)\n      // after the ViewTransition (otherwise it looks bad)\n      if (STATE.isPinching) {\n        resetZoom(STATE, IMAGE);\n      }\n      leaveSlide(STATE, STATE.currentIndex);\n      STATE.lightbox.close();\n      STATE.lightbox.classList.remove('parvus--is-closing');\n      STATE.lightbox.classList.remove('parvus--is-vertical-closing');\n      STATE.GROUPS[STATE.activeGroup].slider.remove();\n      STATE.GROUPS[STATE.activeGroup].slider = null;\n      STATE.GROUPS[STATE.activeGroup].sliderElements = [];\n      STATE.GROUPS[STATE.activeGroup].contentElements = [];\n      STATE.counter.removeAttribute('aria-hidden');\n      STATE.previousButton.removeAttribute('aria-hidden');\n      STATE.previousButton.removeAttribute('aria-disabled');\n      STATE.nextButton.removeAttribute('aria-hidden');\n      STATE.nextButton.removeAttribute('aria-disabled');\n      if (STATE.config.hideScrollbar) {\n        document.body.style.marginInlineEnd = '';\n        document.body.style.overflow = '';\n      }\n\n      // Execute afterClose hook\n      PLUGIN_MANAGER.executeHook('afterClose', {\n        state: STATE\n      });\n    };\n    if (IMAGE && IMAGE.tagName === 'IMG') {\n      if (document.startViewTransition) {\n        IMAGE.style.viewTransitionName = 'lightboximage';\n        const transition = document.startViewTransition(() => {\n          IMAGE.style.opacity = '0';\n          IMAGE.style.viewTransitionName = null;\n          THUMBNAIL.style.viewTransitionName = 'lightboximage';\n        });\n        transition.finished.finally(() => {\n          transitionendHandler();\n          THUMBNAIL.style.viewTransitionName = null;\n        });\n      } else {\n        IMAGE.style.opacity = '0';\n        requestAnimationFrame(transitionendHandler);\n      }\n    } else {\n      transitionendHandler();\n    }\n  };\n\n  /**\n   * Select a specific slide by index\n   *\n   * @param {number} index - Index of the slide to select\n   */\n  const select = index => {\n    if (!isOpen()) {\n      throw new Error(\"Oops, I'm closed.\");\n    }\n    if (typeof index !== 'number' || isNaN(index)) {\n      throw new Error('Oops, no slide specified.');\n    }\n    const GROUP = STATE.GROUPS[STATE.activeGroup];\n    const triggerElements = GROUP.triggerElements;\n    if (index === STATE.currentIndex) {\n      throw new Error(`Oops, slide ${index} is already selected.`);\n    }\n    if (index < 0 || index >= triggerElements.length) {\n      throw new Error(`Oops, I can't find slide ${index}.`);\n    }\n    const OLD_INDEX = STATE.currentIndex;\n    STATE.currentIndex = index;\n    if (GROUP.sliderElements[index]) {\n      loadSlide(STATE, index);\n    } else {\n      createSlide(STATE, index);\n      createImage(STATE, GROUP.triggerElements[index], index, () => {\n        loadImage(STATE, index);\n      });\n      loadSlide(STATE, index);\n    }\n    updateOffset(STATE);\n    updateSliderNavigationStatus(STATE);\n    updateCounter(STATE);\n\n    // Execute slideChange hook\n    PLUGIN_MANAGER.executeHook('slideChange', {\n      index,\n      oldIndex: OLD_INDEX,\n      state: STATE\n    });\n    if (index < OLD_INDEX) {\n      preload(STATE, createSlide, createImage, loadImage, index - 1);\n    } else {\n      preload(STATE, createSlide, createImage, loadImage, index + 1);\n    }\n    leaveSlide(STATE, OLD_INDEX);\n\n    // Create and dispatch a new event\n    dispatchCustomEvent(STATE.lightbox, 'select');\n  };\n\n  /**\n   * Select the previous slide\n   */\n  const previous = () => {\n    if (STATE.currentIndex > 0) {\n      select(STATE.currentIndex - 1);\n    }\n  };\n\n  /**\n   * Select the next slide\n   */\n  const next = () => {\n    const {\n      triggerElements\n    } = STATE.GROUPS[STATE.activeGroup];\n    if (STATE.currentIndex < triggerElements.length - 1) {\n      select(STATE.currentIndex + 1);\n    }\n  };\n\n  /**\n   * Bind specified events\n   */\n  const bindEvents = () => {\n    const actions = {\n      close,\n      previous,\n      next,\n      updateOffset: () => updateOffset(STATE)\n    };\n\n    // Create handlers with state and actions\n    keydownHandler = createKeydownHandler(STATE, actions);\n    clickHandler = createClickHandler(STATE, actions);\n    resizeHandler = createResizeHandler(STATE, () => updateOffset(STATE));\n    const updateAfterDragHandler = () => updateAfterDrag(STATE, actions);\n    const pinchZoomHandler = img => pinchZoom(STATE, img);\n    const doSwipeHandler = () => doSwipe(STATE);\n    const resetZoomHandler = img => resetZoom(STATE, img);\n    pointerdownHandler = createPointerdownHandler(STATE);\n    pointermoveHandler = createPointermoveHandler(STATE, pinchZoomHandler, doSwipeHandler);\n    pointerupHandler = createPointerupHandler(STATE, resetZoomHandler, updateAfterDragHandler);\n    BROWSER_WINDOW.addEventListener('keydown', keydownHandler);\n    BROWSER_WINDOW.addEventListener('resize', resizeHandler);\n\n    // Popstate event\n    BROWSER_WINDOW.addEventListener('popstate', close);\n\n    // Check for any OS level changes to the prefers reduced motion preference\n    MOTIONQUERY.addEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY));\n\n    // Click event\n    STATE.lightbox.addEventListener('click', clickHandler);\n\n    // Pointer events\n    STATE.lightbox.addEventListener('pointerdown', pointerdownHandler, {\n      passive: false\n    });\n    STATE.lightbox.addEventListener('pointerup', pointerupHandler, {\n      passive: true\n    });\n    STATE.lightbox.addEventListener('pointermove', pointermoveHandler, {\n      passive: false\n    });\n  };\n\n  /**\n   * Unbind specified events\n   */\n  const unbindEvents = () => {\n    BROWSER_WINDOW.removeEventListener('keydown', keydownHandler);\n    BROWSER_WINDOW.removeEventListener('resize', resizeHandler);\n\n    // Popstate event\n    BROWSER_WINDOW.removeEventListener('popstate', close);\n\n    // Check for any OS level changes to the prefers reduced motion preference\n    MOTIONQUERY.removeEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY));\n\n    // Click event\n    STATE.lightbox.removeEventListener('click', clickHandler);\n\n    // Pointer events\n    STATE.lightbox.removeEventListener('pointerdown', pointerdownHandler);\n    STATE.lightbox.removeEventListener('pointerup', pointerupHandler);\n    STATE.lightbox.removeEventListener('pointermove', pointermoveHandler);\n  };\n\n  /**\n   * Destroy Parvus\n   */\n  const destroy = () => {\n    if (!STATE.lightbox) {\n      return;\n    }\n    if (isOpen()) {\n      close();\n    }\n\n    // Add setTimeout to ensure all possible close transitions are completed\n    setTimeout(() => {\n      unbindEvents();\n\n      // Remove all registered event listeners for custom events\n      const eventTypes = ['open', 'close', 'select', 'destroy'];\n      eventTypes.forEach(eventType => {\n        const listeners = STATE.lightbox._listeners?.[eventType] || [];\n        listeners.forEach(listener => {\n          STATE.lightbox.removeEventListener(eventType, listener);\n        });\n      });\n\n      // Remove event listeners from trigger elements\n      const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll('.parvus-trigger');\n      LIGHTBOX_TRIGGER_ELS.forEach(el => {\n        el.removeEventListener('click', triggerParvus);\n        el.classList.remove('parvus-trigger');\n        if (STATE.config.zoomIndicator) {\n          removeZoomIndicator(el);\n        }\n        if (el.dataset.group) {\n          delete el.dataset.group;\n        }\n      });\n\n      // Create and dispatch a new event\n      dispatchCustomEvent(STATE.lightbox, 'destroy');\n      STATE.lightbox.remove();\n\n      // Remove references\n      STATE.lightbox = null;\n      STATE.lightboxOverlay = null;\n      STATE.toolbar = null;\n      STATE.toolbarLeft = null;\n      STATE.toolbarRight = null;\n      STATE.controls = null;\n      STATE.previousButton = null;\n      STATE.nextButton = null;\n      STATE.closeButton = null;\n      STATE.counter = null;\n\n      // Remove group data\n      Object.keys(STATE.GROUPS).forEach(groupKey => {\n        const group = STATE.GROUPS[groupKey];\n        if (group && group.contentElements) {\n          group.contentElements.forEach(content => {\n            if (content && content.tagName === 'IMG') {\n              content.src = '';\n              content.srcset = '';\n            }\n          });\n        }\n        delete STATE.GROUPS[groupKey];\n      });\n\n      // Reset variables\n      STATE.groupIdCounter = 0;\n      STATE.newGroup = null;\n      STATE.activeGroup = null;\n      STATE.currentIndex = 0;\n    }, 1000);\n  };\n\n  /**\n   * Check if Parvus is open\n   *\n   * @returns {boolean} - True if Parvus is open, otherwise false\n   */\n  const isOpen = () => {\n    return STATE.lightbox?.hasAttribute('open');\n  };\n\n  /**\n   * Get the current index\n   *\n   * @returns {number} - The current index\n   */\n  const getCurrentIndex = () => {\n    return STATE.currentIndex;\n  };\n\n  /**\n   * Bind a specific event listener\n   *\n   * @param {String} eventName - The name of the event to bind\n   * @param {Function} callback - The callback function\n   */\n  const on$1 = (eventName, callback) => {\n    on(STATE.lightbox, eventName, callback);\n  };\n\n  /**\n   * Unbind a specific event listener\n   *\n   * @param {String} eventName - The name of the event to unbind\n   * @param {Function} callback - The callback function\n   */\n  const off$1 = (eventName, callback) => {\n    off(STATE.lightbox, eventName, callback);\n  };\n\n  /**\n   * Use a plugin\n   *\n   * @param {Object} plugin - Plugin object\n   * @param {Object} options - Plugin options\n   */\n  const use = (plugin, options = {}) => {\n    PLUGIN_MANAGER.register(plugin, options);\n  };\n\n  /**\n   * Add a hook callback\n   *\n   * @param {String} hookName - Hook name\n   * @param {Function} callback - Callback function\n   */\n  const addHook = (hookName, callback) => {\n    PLUGIN_MANAGER.addHook(hookName, callback);\n  };\n\n  /**\n   * Get registered plugins\n   *\n   * @returns {Array} Array of plugin names\n   */\n  const getPlugins = () => {\n    return PLUGIN_MANAGER.getPlugins();\n  };\n\n  /**\n   * Init\n   */\n  const init = () => {\n    // Merge user options into defaults\n    STATE.config = mergeOptions(userOptions);\n    reducedMotionCheck(STATE, MOTIONQUERY);\n\n    // Install plugins with context\n    const pluginContext = {\n      state: STATE,\n      on: on,\n      addHook: PLUGIN_MANAGER.addHook.bind(PLUGIN_MANAGER),\n      config: STATE.config\n    };\n    PLUGIN_MANAGER.install(pluginContext);\n    if (STATE.config.gallerySelector !== null) {\n      // Get a list of all `gallerySelector` elements within the document\n      const GALLERY_ELS = document.querySelectorAll(STATE.config.gallerySelector);\n\n      // Execute a few things once per element\n      GALLERY_ELS.forEach((galleryEl, index) => {\n        const GALLERY_INDEX = index;\n        // Get a list of all `selector` elements within the `gallerySelector`\n        const LIGHTBOX_TRIGGER_GALLERY_ELS = galleryEl.querySelectorAll(STATE.config.selector);\n\n        // Execute a few things once per element\n        LIGHTBOX_TRIGGER_GALLERY_ELS.forEach(lightboxTriggerEl => {\n          lightboxTriggerEl.setAttribute('data-group', `parvus-gallery-${GALLERY_INDEX}`);\n          add(lightboxTriggerEl);\n        });\n      });\n    }\n\n    // Get a list of all `selector` elements outside or without the `gallerySelector`\n    const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(`${STATE.config.selector}:not(.parvus-trigger)`);\n    LIGHTBOX_TRIGGER_ELS.forEach(add);\n  };\n  init();\n  return {\n    init,\n    open,\n    close,\n    select,\n    previous,\n    next,\n    currentIndex: getCurrentIndex,\n    add,\n    remove,\n    destroy,\n    isOpen,\n    on: on$1,\n    off: off$1,\n    use,\n    addHook,\n    getPlugins\n  };\n}\n\nexport { Parvus as default };\n"
  },
  {
    "path": "dist/js/parvus.js",
    "content": "/**\n * Parvus\n *\n * @author Benjamin de Oostfrees\n * @version 3.1.0\n * @url https://github.com/deoostfrees/parvus\n *\n * MIT license\n */\n\n(function (global, factory) {\n  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :\n  typeof define === 'function' && define.amd ? define(factory) :\n  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Parvus = factory());\n})(this, (function () { 'use strict';\n\n  const BROWSER_WINDOW = window;\n\n  /**\n   * Get scrollbar width\n   *\n   * @return {Number} - The scrollbar width\n   */\n  const getScrollbarWidth = () => {\n    return BROWSER_WINDOW.innerWidth - document.documentElement.clientWidth;\n  };\n  const FOCUSABLE_ELEMENTS = ['a:not([inert]):not([tabindex^=\"-\"])', 'button:not([inert]):not([tabindex^=\"-\"]):not(:disabled)', '[tabindex]:not([inert]):not([tabindex^=\"-\"])'];\n\n  /**\n   * Get the focusable children of the given element\n   *\n   * @return {Array<Element>} - An array of focusable children\n   */\n  const getFocusableChildren = targetEl => {\n    return Array.from(targetEl.querySelectorAll(FOCUSABLE_ELEMENTS.join(', '))).filter(child => child.offsetParent !== null);\n  };\n\n  var en = {\n    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.',\n    lightboxLoadingIndicatorLabel: 'Image loading',\n    lightboxLoadingError: 'The requested image cannot be loaded.',\n    controlsLabel: 'Controls',\n    previousButtonLabel: 'Previous image',\n    nextButtonLabel: 'Next image',\n    closeButtonLabel: 'Close dialog window',\n    sliderLabel: 'Images',\n    slideLabel: 'Image'\n  };\n\n  /**\n   * Default configuration options\n   */\n  const DEFAULT_OPTIONS = {\n    selector: '.lightbox',\n    gallerySelector: null,\n    zoomIndicator: true,\n    captions: true,\n    captionsSelector: 'self',\n    captionsAttribute: 'data-caption',\n    copyright: true,\n    copyrightSelector: 'self',\n    copyrightAttribute: 'data-copyright',\n    docClose: true,\n    swipeClose: true,\n    simulateTouch: true,\n    threshold: 50,\n    hideScrollbar: true,\n    lightboxIndicatorIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" stroke=\"currentColor\"><path d=\"M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3\"/></svg>',\n    previousButtonIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1\" stroke=\"currentColor\"><path stroke=\"none\" d=\"M0 0h24v24H0z\"/><polyline points=\"15 6 9 12 15 18\" /></svg>',\n    nextButtonIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1\" stroke=\"currentColor\"><path stroke=\"none\" d=\"M0 0h24v24H0z\"/><polyline points=\"9 6 15 12 9 18\" /></svg>',\n    closeButtonIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1\" stroke=\"currentColor\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>',\n    l10n: en\n  };\n\n  /**\n   * Merge default options with user-provided options\n   *\n   * @param {Object} userOptions - User-provided options\n   * @returns {Object} - Merged options object\n   */\n  const mergeOptions = userOptions => {\n    const MERGED_OPTIONS = {\n      ...DEFAULT_OPTIONS,\n      ...userOptions\n    };\n    if (userOptions && userOptions.l10n) {\n      MERGED_OPTIONS.l10n = {\n        ...DEFAULT_OPTIONS.l10n,\n        ...userOptions.l10n\n      };\n    }\n    return MERGED_OPTIONS;\n  };\n\n  /**\n   * State management for Parvus\n   *\n   * Centralizes all mutable state variables\n   */\n  class ParvusState {\n    constructor() {\n      // Group management\n      this.GROUP_ATTRIBUTES = {\n        triggerElements: [],\n        slider: null,\n        sliderElements: [],\n        contentElements: []\n      };\n      this.GROUPS = {};\n      this.groupIdCounter = 0;\n      this.newGroup = null;\n      this.activeGroup = null;\n      this.currentIndex = 0;\n\n      // Configuration\n      this.config = {};\n\n      // DOM elements\n      this.lightbox = null;\n      this.lightboxOverlay = null;\n      this.lightboxOverlayOpacity = 1;\n      this.toolbar = null;\n      this.toolbarLeft = null;\n      this.toolbarRight = null;\n      this.controls = null;\n      this.previousButton = null;\n      this.nextButton = null;\n      this.closeButton = null;\n      this.counter = null;\n\n      // Drag & interaction state\n      this.drag = {};\n      this.isDraggingX = false;\n      this.isDraggingY = false;\n      this.pointerDown = false;\n      this.activePointers = new Map();\n\n      // Zoom state\n      this.currentScale = 1;\n      this.isPinching = false;\n      this.isTap = false;\n      this.pinchStartDistance = 0;\n      this.lastPointersId = null;\n\n      // Offset & animation\n      this.offset = null;\n      this.offsetTmp = null;\n      this.resizeTicking = false;\n      this.isReducedMotion = true;\n    }\n\n    /**\n     * Clear drag state\n     */\n    clearDrag() {\n      this.drag = {\n        startX: 0,\n        endX: 0,\n        startY: 0,\n        endY: 0\n      };\n    }\n\n    /**\n     * Get the active group\n     *\n     * @returns {Object} The active group\n     */\n    getActiveGroup() {\n      return this.GROUPS[this.activeGroup];\n    }\n\n    /**\n     * Reset zoom state\n     */\n    resetZoomState() {\n      this.isPinching = false;\n      this.isTap = false;\n      this.currentScale = 1;\n      this.pinchStartDistance = 0;\n      this.lastPointersId = '';\n    }\n  }\n\n  /**\n   * Event System Module\n   *\n   * Handles custom event dispatching and listeners\n   */\n\n  /**\n   * Dispatch a custom event\n   *\n   * @param {HTMLElement} lightbox - The lightbox element\n   * @param {String} type - The type of the event to dispatch\n   * @returns {void}\n   */\n  const dispatchCustomEvent = (lightbox, type) => {\n    const CUSTOM_EVENT = new CustomEvent(type, {\n      cancelable: true\n    });\n    lightbox.dispatchEvent(CUSTOM_EVENT);\n  };\n\n  /**\n   * Bind a specific event listener\n   *\n   * @param {HTMLElement} lightbox - The lightbox element\n   * @param {String} eventName - The name of the event to bind\n   * @param {Function} callback - The callback function\n   * @returns {void}\n   */\n  const on = (lightbox, eventName, callback) => {\n    if (lightbox) {\n      lightbox.addEventListener(eventName, callback);\n    }\n  };\n\n  /**\n   * Unbind a specific event listener\n   *\n   * @param {HTMLElement} lightbox - The lightbox element\n   * @param {String} eventName - The name of the event to unbind\n   * @param {Function} callback - The callback function\n   * @returns {void}\n   */\n  const off = (lightbox, eventName, callback) => {\n    if (lightbox) {\n      lightbox.removeEventListener(eventName, callback);\n    }\n  };\n\n  /**\n   * Navigation Module\n   *\n   * Handles slide navigation and transitions\n   */\n\n  /**\n   * Update offset\n   *\n   * @param {Object} state - The application state\n   * @returns {void}\n   */\n  const updateOffset = state => {\n    state.activeGroup = state.activeGroup !== null ? state.activeGroup : state.newGroup;\n    state.offset = -state.currentIndex * state.lightbox.offsetWidth;\n    state.GROUPS[state.activeGroup].slider.style.transform = `translate3d(${state.offset}px, 0, 0)`;\n    state.offsetTmp = state.offset;\n  };\n\n  /**\n   * Load slide with the specified index\n   *\n   * @param {Object} state - The application state\n   * @param {Number} index - The index of the slide to be loaded\n   * @returns {void}\n   */\n  const loadSlide = (state, index) => {\n    state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'false');\n  };\n\n  /**\n   * Leave slide\n   *\n   * @param {Object} state - The application state\n   * @param {Number} index - The index of the slide to leave\n   * @returns {void}\n   */\n  const leaveSlide = (state, index) => {\n    if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {\n      state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'true');\n    }\n  };\n\n  /**\n   * Preload slide with the specified index\n   *\n   * @param {Object} state - The application state\n   * @param {Function} createSlide - Create slide function\n   * @param {Function} createImage - Create image function\n   * @param {Function} loadImage - Load image function\n   * @param {Number} index - The index of the slide to be preloaded\n   * @returns {void}\n   */\n  const preload = (state, createSlide, createImage, loadImage, index) => {\n    if (index < 0 || index >= state.GROUPS[state.activeGroup].triggerElements.length || state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {\n      return;\n    }\n    createSlide(state, index);\n    createImage(state, state.GROUPS[state.activeGroup].triggerElements[index], index, () => {\n      loadImage(state, index);\n    });\n  };\n\n  /**\n   * Utils Module\n   *\n   * Utility functions\n   */\n\n  /**\n   * Check prefers reduced motion\n   *\n   * @param {Object} state - The application state\n   * @param {MediaQueryList} motionQuery - The media query list\n   * @returns {void}\n   */\n  const reducedMotionCheck = (state, motionQuery) => {\n    if (motionQuery.matches) {\n      state.isReducedMotion = true;\n    } else {\n      state.isReducedMotion = false;\n    }\n  };\n\n  /**\n   * Retrieves or creates a group identifier for the given element\n   *\n   * @param {Object} state - The application state\n   * @param {HTMLElement} el - DOM element to get or assign a group to\n   * @returns {string} The group identifier associated with the element\n   */\n  const getGroup = (state, el) => {\n    // Return existing group identifier if already assigned\n    if (el.dataset.group) {\n      return el.dataset.group;\n    }\n\n    // Generate new unique group identifier using counter\n    const EL_GROUP = `default-${state.groupIdCounter++}`;\n\n    // Assign the new group identifier to element's dataset\n    el.dataset.group = EL_GROUP;\n    return EL_GROUP;\n  };\n\n  /**\n   * Plugin management for Parvus\n   *\n   * Provides a system for registering and managing plugins\n   */\n\n  class PluginManager {\n    constructor() {\n      this.plugins = [];\n      this.hooks = {};\n      this.context = null;\n      this.isInitialized = false;\n    }\n\n    /**\n     * Register a plugin\n     *\n     * @param {Object} plugin - Plugin object with name and install function\n     * @param {Object} options - Plugin-specific options\n     */\n    register(plugin, options = {}) {\n      if (!plugin || typeof plugin.install !== 'function') {\n        throw new Error('Plugin must have an install function');\n      }\n      if (!plugin.name) {\n        throw new Error('Plugin must have a name');\n      }\n\n      // Check if plugin is already registered\n      const existingPlugin = this.plugins.find(p => p.name === plugin.name);\n      if (existingPlugin) {\n        console.warn(`Plugin \"${plugin.name}\" is already registered`);\n        return;\n      }\n      this.plugins.push({\n        plugin,\n        options\n      });\n\n      // If already initialized, install immediately\n      if (this.isInitialized && this.context) {\n        this.installPlugin(plugin, options);\n      }\n    }\n\n    /**\n     * Install a single plugin\n     *\n     * @param {Object} plugin - Plugin object\n     * @param {Object} options - Plugin options\n     */\n    installPlugin(plugin, options) {\n      try {\n        plugin.install(this.context, options);\n\n        // If lightbox already exists, execute afterInit hook for this plugin immediately\n        if (this.context && this.context.state && this.context.state.lightbox) {\n          this.executeHook('afterInit', {\n            state: this.context.state\n          });\n        }\n      } catch (error) {\n        console.error(`Failed to install plugin \"${plugin.name}\":`, error);\n      }\n    }\n\n    /**\n     * Install all registered plugins\n     *\n     * @param {Object} context - Parvus instance context\n     */\n    install(context) {\n      this.context = context;\n      this.isInitialized = true;\n      this.plugins.forEach(({\n        plugin,\n        options\n      }) => {\n        this.installPlugin(plugin, options);\n      });\n    }\n\n    /**\n     * Execute a hook\n     *\n     * @param {String} hookName - Name of the hook\n     * @param {*} data - Data to pass to hook callbacks\n     */\n    executeHook(hookName, data) {\n      const callbacks = this.hooks[hookName] || [];\n      callbacks.forEach(callback => {\n        try {\n          callback(data);\n        } catch (error) {\n          console.error(`Error in hook \"${hookName}\":`, error);\n        }\n      });\n    }\n\n    /**\n     * Register a hook callback\n     *\n     * @param {String} hookName - Name of the hook\n     * @param {Function} callback - Callback function\n     */\n    addHook(hookName, callback) {\n      if (!this.hooks[hookName]) {\n        this.hooks[hookName] = [];\n      }\n      this.hooks[hookName].push(callback);\n    }\n\n    /**\n     * Remove a hook callback\n     *\n     * @param {String} hookName - Name of the hook\n     * @param {Function} callback - Callback function to remove\n     */\n    removeHook(hookName, callback) {\n      if (!this.hooks[hookName]) return;\n      this.hooks[hookName] = this.hooks[hookName].filter(cb => cb !== callback);\n    }\n\n    /**\n     * Get all registered plugins\n     *\n     * @returns {Array} Array of plugin names\n     */\n    getPlugins() {\n      return this.plugins.map(p => p.plugin.name);\n    }\n  }\n\n  /**\n   * UI Components Module\n   *\n   * Handles creation of lightbox, toolbar, slider and slides\n   */\n\n  /**\n   * Create the lightbox\n   *\n   * @param {Object} state - The application state\n   * @returns {void}\n   */\n  const createLightbox = state => {\n    const {\n      config\n    } = state;\n\n    // Use DocumentFragment to batch DOM operations\n    const fragment = document.createDocumentFragment();\n\n    // Create the lightbox container\n    state.lightbox = document.createElement('dialog');\n    state.lightbox.setAttribute('role', 'dialog');\n    state.lightbox.setAttribute('aria-modal', 'true');\n    state.lightbox.setAttribute('aria-label', config.l10n.lightboxLabel);\n    state.lightbox.classList.add('parvus');\n\n    // Create the lightbox overlay container\n    state.lightboxOverlay = document.createElement('div');\n    state.lightboxOverlay.classList.add('parvus__overlay');\n\n    // Create the toolbar\n    state.toolbar = document.createElement('div');\n    state.toolbar.className = 'parvus__toolbar';\n\n    // Create the toolbar items\n    state.toolbarLeft = document.createElement('div');\n    state.toolbarRight = document.createElement('div');\n\n    // Create the controls\n    state.controls = document.createElement('div');\n    state.controls.className = 'parvus__controls';\n    state.controls.setAttribute('role', 'group');\n    state.controls.setAttribute('aria-label', config.l10n.controlsLabel);\n\n    // Create the close button\n    state.closeButton = document.createElement('button');\n    state.closeButton.className = 'parvus__btn parvus__btn--close';\n    state.closeButton.setAttribute('type', 'button');\n    state.closeButton.setAttribute('aria-label', config.l10n.closeButtonLabel);\n    state.closeButton.innerHTML = config.closeButtonIcon;\n\n    // Create the previous button\n    state.previousButton = document.createElement('button');\n    state.previousButton.className = 'parvus__btn parvus__btn--previous';\n    state.previousButton.setAttribute('type', 'button');\n    state.previousButton.setAttribute('aria-label', config.l10n.previousButtonLabel);\n    state.previousButton.innerHTML = config.previousButtonIcon;\n\n    // Create the next button\n    state.nextButton = document.createElement('button');\n    state.nextButton.className = 'parvus__btn parvus__btn--next';\n    state.nextButton.setAttribute('type', 'button');\n    state.nextButton.setAttribute('aria-label', config.l10n.nextButtonLabel);\n    state.nextButton.innerHTML = config.nextButtonIcon;\n\n    // Create the counter\n    state.counter = document.createElement('div');\n    state.counter.className = 'parvus__counter';\n\n    // Add the control buttons to the controls\n    state.controls.append(state.closeButton, state.previousButton, state.nextButton);\n\n    // Add the counter to the left toolbar item\n    state.toolbarLeft.appendChild(state.counter);\n\n    // Add the controls to the right toolbar item\n    state.toolbarRight.appendChild(state.controls);\n\n    // Add the toolbar items to the toolbar\n    state.toolbar.append(state.toolbarLeft, state.toolbarRight);\n\n    // Add the overlay and the toolbar to the lightbox\n    state.lightbox.append(state.lightboxOverlay, state.toolbar);\n    fragment.appendChild(state.lightbox);\n\n    // Add to document body\n    document.body.appendChild(fragment);\n  };\n\n  /**\n   * Create a slider\n   *\n   * @param {Object} state - The application state\n   * @returns {void}\n   */\n  const createSlider = state => {\n    const SLIDER = document.createElement('div');\n    SLIDER.className = 'parvus__slider';\n\n    // Update the slider reference in GROUPS\n    state.GROUPS[state.activeGroup].slider = SLIDER;\n\n    // Add the slider to the lightbox container\n    state.lightbox.appendChild(SLIDER);\n  };\n\n  /**\n   * Get next slide index\n   *\n   * @param {Object} state - The application state\n   * @param {Number} currentIndex - Current slide index\n   * @returns {number} Index of the next available slide or -1 if none found\n   */\n  const getNextSlideIndex = (state, currentIndex) => {\n    const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;\n    const TOTAL_SLIDE_ELEMENTS = SLIDE_ELEMENTS.length;\n    for (let i = currentIndex + 1; i < TOTAL_SLIDE_ELEMENTS; i++) {\n      if (SLIDE_ELEMENTS[i] !== undefined) {\n        return i;\n      }\n    }\n    return -1;\n  };\n\n  /**\n   * Get previous slide index\n   *\n   * @param {Object} state - The application state\n   * @param {number} currentIndex - Current slide index\n   * @returns {number} Index of the previous available slide or -1 if none found\n   */\n  const getPreviousSlideIndex = (state, currentIndex) => {\n    const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;\n    for (let i = currentIndex - 1; i >= 0; i--) {\n      if (SLIDE_ELEMENTS[i] !== undefined) {\n        return i;\n      }\n    }\n    return -1;\n  };\n\n  /**\n   * Create a slide\n   *\n   * @param {Object} state - The application state\n   * @param {Number} index - The index of the slide\n   * @returns {void}\n   */\n  const createSlide = (state, index) => {\n    if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {\n      return;\n    }\n    const FRAGMENT = document.createDocumentFragment();\n    const SLIDE_ELEMENT = document.createElement('div');\n    const SLIDE_ELEMENT_CONTENT = document.createElement('div');\n    const GROUP = state.GROUPS[state.activeGroup];\n    const TOTAL_TRIGGER_ELEMENTS = GROUP.triggerElements.length;\n    SLIDE_ELEMENT.className = 'parvus__slide';\n    SLIDE_ELEMENT.style.cssText = `\n    position: absolute;\n    left: ${index * 100}%;\n  `;\n    SLIDE_ELEMENT.setAttribute('aria-hidden', 'true');\n\n    // Add accessibility attributes if gallery has multiple slides\n    if (TOTAL_TRIGGER_ELEMENTS > 1) {\n      SLIDE_ELEMENT.setAttribute('role', 'group');\n      SLIDE_ELEMENT.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`);\n    }\n    SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT);\n    FRAGMENT.appendChild(SLIDE_ELEMENT);\n    GROUP.sliderElements[index] = SLIDE_ELEMENT;\n\n    // Insert the slide element based on index position\n    if (index >= state.currentIndex) {\n      // Insert the slide element after the current slide\n      const NEXT_SLIDE_INDEX = getNextSlideIndex(state, index);\n      if (NEXT_SLIDE_INDEX !== -1) {\n        GROUP.sliderElements[NEXT_SLIDE_INDEX].before(SLIDE_ELEMENT);\n      } else {\n        GROUP.slider.appendChild(SLIDE_ELEMENT);\n      }\n    } else {\n      // Insert the slide element before the current slide\n      const PREVIOUS_SLIDE_INDEX = getPreviousSlideIndex(state, index);\n      if (PREVIOUS_SLIDE_INDEX !== -1) {\n        GROUP.sliderElements[PREVIOUS_SLIDE_INDEX].after(SLIDE_ELEMENT);\n      } else {\n        GROUP.slider.prepend(SLIDE_ELEMENT);\n      }\n    }\n  };\n\n  /**\n   * Update counter\n   *\n   * @param {Object} state - The application state\n   * @returns {void}\n   */\n  const updateCounter = state => {\n    state.counter.textContent = `${state.currentIndex + 1}/${state.GROUPS[state.activeGroup].triggerElements.length}`;\n  };\n\n  /**\n   * Update Attributes\n   *\n   * @param {Object} state - The application state\n   * @returns {void}\n   */\n  const updateAttributes = state => {\n    const TRIGGER_ELEMENTS = state.GROUPS[state.activeGroup].triggerElements;\n    const TOTAL_TRIGGER_ELEMENTS = TRIGGER_ELEMENTS.length;\n    const SLIDER = state.GROUPS[state.activeGroup].slider;\n    const SLIDER_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;\n    const IS_DRAGGABLE = SLIDER.classList.contains('parvus__slider--is-draggable');\n\n    // Add draggable class if necessary\n    if (state.config.simulateTouch && state.config.swipeClose && !IS_DRAGGABLE || state.config.simulateTouch && TOTAL_TRIGGER_ELEMENTS > 1 && !IS_DRAGGABLE) {\n      SLIDER.classList.add('parvus__slider--is-draggable');\n    } else {\n      SLIDER.classList.remove('parvus__slider--is-draggable');\n    }\n\n    // Add extra output for screen reader if there is more than one slide\n    if (TOTAL_TRIGGER_ELEMENTS > 1) {\n      SLIDER.setAttribute('role', 'region');\n      SLIDER.setAttribute('aria-roledescription', 'carousel');\n      SLIDER.setAttribute('aria-label', state.config.l10n.sliderLabel);\n      SLIDER_ELEMENTS.forEach((sliderElement, index) => {\n        sliderElement.setAttribute('role', 'group');\n        sliderElement.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`);\n      });\n    } else {\n      SLIDER.removeAttribute('role');\n      SLIDER.removeAttribute('aria-roledescription');\n      SLIDER.removeAttribute('aria-label');\n      SLIDER_ELEMENTS.forEach(sliderElement => {\n        sliderElement.removeAttribute('role');\n        sliderElement.removeAttribute('aria-label');\n      });\n    }\n\n    // Show or hide buttons\n    if (TOTAL_TRIGGER_ELEMENTS === 1) {\n      state.counter.setAttribute('aria-hidden', 'true');\n      state.previousButton.setAttribute('aria-hidden', 'true');\n      state.nextButton.setAttribute('aria-hidden', 'true');\n    } else {\n      state.counter.removeAttribute('aria-hidden');\n      state.previousButton.removeAttribute('aria-hidden');\n      state.nextButton.removeAttribute('aria-hidden');\n    }\n  };\n\n  /**\n   * Update slider navigation status\n   *\n   * @param {Object} state - The application state\n   * @returns {void}\n   */\n  const updateSliderNavigationStatus = state => {\n    const {\n      triggerElements\n    } = state.GROUPS[state.activeGroup];\n    const TOTAL_TRIGGER_ELEMENTS = triggerElements.length;\n    if (TOTAL_TRIGGER_ELEMENTS <= 1) {\n      return;\n    }\n\n    // Determine navigation state\n    const FIRST_SLIDE = state.currentIndex === 0;\n    const LAST_SLIDE = state.currentIndex === TOTAL_TRIGGER_ELEMENTS - 1;\n\n    // Set previous button state\n    const PREV_DISABLED = FIRST_SLIDE ? 'true' : null;\n    if (state.previousButton.getAttribute('aria-disabled') === 'true' !== !!PREV_DISABLED) {\n      PREV_DISABLED ? state.previousButton.setAttribute('aria-disabled', 'true') : state.previousButton.removeAttribute('aria-disabled');\n    }\n\n    // Set next button state\n    const NEXT_DISABLED = LAST_SLIDE ? 'true' : null;\n    if (state.nextButton.getAttribute('aria-disabled') === 'true' !== !!NEXT_DISABLED) {\n      NEXT_DISABLED ? state.nextButton.setAttribute('aria-disabled', 'true') : state.nextButton.removeAttribute('aria-disabled');\n    }\n  };\n\n  /**\n   * Add zoom indicator to element\n   *\n   * @param {HTMLElement} el - The element to add the zoom indicator to\n   * @param {Object} config - Options object\n   */\n  const addZoomIndicator = (el, config) => {\n    if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') === null) {\n      const LIGHTBOX_INDICATOR_ICON = document.createElement('div');\n      LIGHTBOX_INDICATOR_ICON.className = 'parvus-zoom__indicator';\n      LIGHTBOX_INDICATOR_ICON.innerHTML = config.lightboxIndicatorIcon;\n      el.appendChild(LIGHTBOX_INDICATOR_ICON);\n    }\n  };\n\n  /**\n   * Remove zoom indicator for element\n   *\n   * @param {HTMLElement} el - The element to remove the zoom indicator to\n   */\n  const removeZoomIndicator = el => {\n    if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') !== null) {\n      const LIGHTBOX_INDICATOR_ICON = el.querySelector('.parvus-zoom__indicator');\n      el.removeChild(LIGHTBOX_INDICATOR_ICON);\n    }\n  };\n\n  /**\n   * Keyboard Event Handler Module\n   *\n   * Handles all keyboard interactions\n   */\n\n\n  /**\n   * Create keyboard event handler\n   *\n   * @param {Object} state - The application state\n   * @param {Object} actions - Actions object with navigation functions\n   * @returns {Function} Keyboard event handler\n   */\n  const createKeydownHandler = (state, actions) => {\n    return event => {\n      const FOCUSABLE_CHILDREN = getFocusableChildren(state.lightbox);\n      const FOCUSED_ITEM_INDEX = FOCUSABLE_CHILDREN.indexOf(document.activeElement);\n      const lastIndex = FOCUSABLE_CHILDREN.length - 1;\n      switch (event.code) {\n        case 'Tab':\n          {\n            // Use the TAB key to navigate backwards and forwards\n            if (event.shiftKey) {\n              // Navigate backwards\n              if (FOCUSED_ITEM_INDEX === 0) {\n                FOCUSABLE_CHILDREN[lastIndex].focus();\n                event.preventDefault();\n              }\n            } else {\n              // Navigate forwards\n              if (FOCUSED_ITEM_INDEX === lastIndex) {\n                FOCUSABLE_CHILDREN[0].focus();\n                event.preventDefault();\n              }\n            }\n            break;\n          }\n        case 'Escape':\n          {\n            // Close Parvus when the ESC key is pressed\n            actions.close();\n            event.preventDefault();\n            break;\n          }\n        case 'ArrowLeft':\n          {\n            // Show the previous slide when the PREV key is pressed\n            actions.previous();\n            event.preventDefault();\n            break;\n          }\n        case 'ArrowRight':\n          {\n            // Show the next slide when the NEXT key is pressed\n            actions.next();\n            event.preventDefault();\n            break;\n          }\n      }\n    };\n  };\n\n  /**\n   * Pointer Event Handler Module\n   *\n   * Handles all pointer interactions (mouse, touch, pen)\n   */\n\n  /**\n   * Create pointerdown event handler\n   *\n   * @param {Object} state - The application state\n   * @returns {Function} Pointerdown event handler\n   */\n  const createPointerdownHandler = state => {\n    return event => {\n      event.preventDefault();\n      event.stopPropagation();\n      state.isDraggingX = false;\n      state.isDraggingY = false;\n      state.pointerDown = true;\n      state.activePointers.set(event.pointerId, event);\n      state.drag.startX = event.pageX;\n      state.drag.startY = event.pageY;\n      state.drag.endX = event.pageX;\n      state.drag.endY = event.pageY;\n      const {\n        slider\n      } = state.GROUPS[state.activeGroup];\n      slider.classList.add('parvus__slider--is-dragging');\n      slider.style.willChange = 'transform';\n      state.isTap = state.activePointers.size === 1;\n      if (state.config.swipeClose) {\n        state.lightboxOverlayOpacity = getComputedStyle(state.lightboxOverlay).opacity;\n      }\n    };\n  };\n\n  /**\n   * Create pointermove event handler\n   *\n   * @param {Object} state - The application state\n   * @param {Function} pinchZoom - Pinch zoom function\n   * @param {Function} doSwipe - Swipe function\n   * @returns {Function} Pointermove event handler\n   */\n  const createPointermoveHandler = (state, pinchZoom, doSwipe) => {\n    return event => {\n      event.preventDefault();\n      if (!state.pointerDown) {\n        return;\n      }\n      const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex];\n\n      // Update pointer position\n      state.activePointers.set(event.pointerId, event);\n\n      // Zoom\n      if (CURRENT_IMAGE && CURRENT_IMAGE.tagName === 'IMG') {\n        if (state.activePointers.size === 2) {\n          pinchZoom(CURRENT_IMAGE);\n          return;\n        }\n        if (state.currentScale > 1) {\n          return;\n        }\n      }\n      state.drag.endX = event.pageX;\n      state.drag.endY = event.pageY;\n      doSwipe();\n    };\n  };\n\n  /**\n   * Create pointerup event handler\n   *\n   * @param {Object} state - The application state\n   * @param {Function} resetZoom - Reset zoom function\n   * @param {Function} updateAfterDrag - Update after drag function\n   * @returns {Function} Pointerup event handler\n   */\n  const createPointerupHandler = (state, resetZoom, updateAfterDrag) => {\n    return event => {\n      event.stopPropagation();\n      const {\n        slider\n      } = state.GROUPS[state.activeGroup];\n      state.activePointers.delete(event.pointerId);\n      if (state.activePointers.size > 0) {\n        return;\n      }\n      state.pointerDown = false;\n      const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex];\n\n      // Reset zoom state by one tap\n      const MOVEMENT_X = Math.abs(state.drag.endX - state.drag.startX);\n      const MOVEMENT_Y = Math.abs(state.drag.endY - state.drag.startY);\n      const IS_TAP = MOVEMENT_X < 8 && MOVEMENT_Y < 8 && !state.isDraggingX && !state.isDraggingY && state.isTap;\n      slider.classList.remove('parvus__slider--is-dragging');\n      slider.style.willChange = '';\n      if (state.currentScale > 1) {\n        if (IS_TAP) {\n          resetZoom(CURRENT_IMAGE);\n        } else {\n          CURRENT_IMAGE.style.transform = `\n          scale(${state.currentScale})\n        `;\n        }\n      } else {\n        if (state.isPinching) {\n          resetZoom(CURRENT_IMAGE);\n        }\n        if (state.drag.endX || state.drag.endY) {\n          updateAfterDrag();\n        }\n      }\n      state.clearDrag();\n    };\n  };\n\n  /**\n   * Create click event handler\n   *\n   * @param {Object} state - The application state\n   * @param {Object} actions - Actions object with navigation functions\n   * @returns {Function} Click event handler\n   */\n  const createClickHandler = (state, actions) => {\n    return event => {\n      const {\n        target\n      } = event;\n      if (target === state.previousButton) {\n        actions.previous();\n      } else if (target === state.nextButton) {\n        actions.next();\n      } else if (target === state.closeButton || state.config.docClose && !state.isDraggingY && !state.isDraggingX && target.classList.contains('parvus__slide')) {\n        actions.close();\n      }\n      event.stopPropagation();\n    };\n  };\n\n  /**\n   * Gesture Handler Module\n   *\n   * Handles gestures like pinch-to-zoom and swipe\n   */\n\n  /**\n   * Reset image zoom\n   *\n   * @param {Object} state - The application state\n   * @param {HTMLImageElement} currentImg - The image\n   * @returns {void}\n   */\n  const resetZoom = (state, currentImg) => {\n    currentImg.style.transition = 'transform 0.3s ease';\n    currentImg.style.transform = '';\n    setTimeout(() => {\n      currentImg.style.transition = '';\n      currentImg.style.transformOrigin = '';\n    }, 300);\n    state.resetZoomState();\n    state.lightbox.classList.remove('parvus--is-zooming');\n  };\n\n  /**\n   * Pinch zoom gesture\n   *\n   * @param {Object} state - The application state\n   * @param {HTMLImageElement} currentImg - The image to zoom\n   * @returns {void}\n   */\n  const pinchZoom = (state, currentImg) => {\n    // Determine current finger positions\n    const POINTS = Array.from(state.activePointers.values());\n\n    // Calculate current distance between fingers\n    const CURRENT_DISTANCE = Math.hypot(POINTS[1].clientX - POINTS[0].clientX, POINTS[1].clientY - POINTS[0].clientY);\n\n    // Calculate the midpoint between the two points\n    const MIDPOINT_X = (POINTS[0].clientX + POINTS[1].clientX) / 2;\n    const MIDPOINT_Y = (POINTS[0].clientY + POINTS[1].clientY) / 2;\n\n    // Convert midpoint to relative position within the image\n    const IMG_RECT = currentImg.getBoundingClientRect();\n    const RELATIVE_X = (MIDPOINT_X - IMG_RECT.left) / IMG_RECT.width;\n    const RELATIVE_Y = (MIDPOINT_Y - IMG_RECT.top) / IMG_RECT.height;\n\n    // When pinch gesture is about to start or the finger IDs have changed\n    // Use a unique ID based on the pointer IDs to recognize changes\n    const CURRENT_POINTERS_ID = POINTS.map(p => p.pointerId).sort().join('-');\n    const IS_NEW_POINTER_COMBINATION = state.lastPointersId !== CURRENT_POINTERS_ID;\n    if (!state.isPinching || IS_NEW_POINTER_COMBINATION) {\n      state.isPinching = true;\n      state.lastPointersId = CURRENT_POINTERS_ID;\n\n      // Save the start distance and current scaling as a basis\n      state.pinchStartDistance = CURRENT_DISTANCE / state.currentScale;\n\n      // Store initial pinch position for this gesture\n      if (!currentImg.style.transformOrigin && state.currentScale === 1 || state.currentScale === 1 && IS_NEW_POINTER_COMBINATION) {\n        // Set the transform origin to the pinch midpoint\n        currentImg.style.transformOrigin = `${RELATIVE_X * 100}% ${RELATIVE_Y * 100}%`;\n      }\n      state.lightbox.classList.add('parvus--is-zooming');\n    }\n\n    // Calculate scaling factor based on distance change\n    const SCALE_FACTOR = CURRENT_DISTANCE / state.pinchStartDistance;\n\n    // Limit scaling to 1 - 3\n    state.currentScale = Math.min(Math.max(1, SCALE_FACTOR), 3);\n    currentImg.style.willChange = 'transform';\n    currentImg.style.transform = `scale(${state.currentScale})`;\n  };\n\n  /**\n   * Determine the swipe direction (horizontal or vertical)\n   *\n   * @param {Object} state - The application state\n   * @returns {void}\n   */\n  const doSwipe = state => {\n    const MOVEMENT_THRESHOLD = 1.5;\n    const MAX_OPACITY_DISTANCE = 100;\n    const DIRECTION_BIAS = 1.15;\n    const {\n      startX,\n      endX,\n      startY,\n      endY\n    } = state.drag;\n    const MOVEMENT_X = startX - endX;\n    const MOVEMENT_Y = endY - startY;\n    const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X);\n    const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y);\n    const GROUP = state.GROUPS[state.activeGroup];\n    const SLIDER = GROUP.slider;\n    const TOTAL_SLIDES = GROUP.triggerElements.length;\n    const handleHorizontalSwipe = (movementX, distance) => {\n      const IS_FIRST_SLIDE = state.currentIndex === 0;\n      const IS_LAST_SLIDE = state.currentIndex === TOTAL_SLIDES - 1;\n      const IS_LEFT_SWIPE = movementX > 0;\n      const IS_RIGHT_SWIPE = movementX < 0;\n      if (IS_FIRST_SLIDE && IS_RIGHT_SWIPE || IS_LAST_SLIDE && IS_LEFT_SWIPE) {\n        const DAMPING_FACTOR = 1 / (1 + Math.pow(distance / 100, 0.15));\n        const REDUCED_MOVEMENT = movementX * DAMPING_FACTOR;\n        SLIDER.style.transform = `\n        translate3d(${state.offsetTmp - Math.round(REDUCED_MOVEMENT)}px, 0, 0)\n      `;\n      } else {\n        SLIDER.style.transform = `\n        translate3d(${state.offsetTmp - Math.round(movementX)}px, 0, 0)\n      `;\n      }\n    };\n    const handleVerticalSwipe = (movementY, distance) => {\n      if (!state.isReducedMotion && distance <= 100) {\n        const NEW_OVERLAY_OPACITY = Math.max(0, state.lightboxOverlayOpacity - distance / MAX_OPACITY_DISTANCE);\n        state.lightboxOverlay.style.opacity = NEW_OVERLAY_OPACITY;\n      }\n      state.lightbox.classList.add('parvus--is-vertical-closing');\n      SLIDER.style.transform = `\n      translate3d(${state.offsetTmp}px, ${Math.round(movementY)}px, 0)\n    `;\n    };\n    if (state.isDraggingX || state.isDraggingY) {\n      if (state.isDraggingX) {\n        handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE);\n      } else if (state.isDraggingY) {\n        handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE);\n      }\n      return;\n    }\n\n    // Direction detection based on the relative ratio of movements\n    if (MOVEMENT_X_DISTANCE > MOVEMENT_THRESHOLD || MOVEMENT_Y_DISTANCE > MOVEMENT_THRESHOLD) {\n      // Horizontal swipe if X-movement is stronger than Y-movement * DIRECTION_BIAS\n      if (MOVEMENT_X_DISTANCE > MOVEMENT_Y_DISTANCE * DIRECTION_BIAS && TOTAL_SLIDES > 1) {\n        state.isDraggingX = true;\n        state.isDraggingY = false;\n        handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE);\n      } else if (MOVEMENT_Y_DISTANCE > MOVEMENT_X_DISTANCE * DIRECTION_BIAS && state.config.swipeClose) {\n        // Vertical swipe if Y-movement is stronger than X-movement * DIRECTION_BIAS\n        state.isDraggingX = false;\n        state.isDraggingY = true;\n        handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE);\n      }\n    }\n  };\n\n  /**\n   * Recalculate drag/swipe event after pointerup\n   *\n   * @param {Object} state - The application state\n   * @param {Object} actions - Navigation actions\n   * @returns {void}\n   */\n  const updateAfterDrag = (state, actions) => {\n    const {\n      startX,\n      startY,\n      endX,\n      endY\n    } = state.drag;\n    const MOVEMENT_X = endX - startX;\n    const MOVEMENT_Y = endY - startY;\n    const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X);\n    const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y);\n    const {\n      triggerElements\n    } = state.GROUPS[state.activeGroup];\n    const TOTAL_TRIGGER_ELEMENTS = triggerElements.length;\n    if (state.isDraggingX) {\n      const IS_RIGHT_SWIPE = MOVEMENT_X > 0;\n      if (MOVEMENT_X_DISTANCE >= state.config.threshold) {\n        if (IS_RIGHT_SWIPE && state.currentIndex > 0) {\n          actions.previous();\n        } else if (!IS_RIGHT_SWIPE && state.currentIndex < TOTAL_TRIGGER_ELEMENTS - 1) {\n          actions.next();\n        }\n      }\n      actions.updateOffset();\n    } else if (state.isDraggingY) {\n      if (MOVEMENT_Y_DISTANCE >= state.config.threshold && state.config.swipeClose) {\n        actions.close();\n      } else {\n        state.lightbox.classList.remove('parvus--is-vertical-closing');\n        actions.updateOffset();\n      }\n      state.lightboxOverlay.style.opacity = '';\n    } else {\n      actions.updateOffset();\n    }\n  };\n\n  /**\n   * Image Handler Module\n   *\n   * Handles image loading, captions, and dimensions\n   */\n\n  /**\n   * Add caption to the container element\n   *\n   * @param {Object} config - Configuration object\n   * @param {HTMLElement} containerEl - The container element to which the caption will be added\n   * @param {HTMLElement} imageEl - The image the caption is linked to\n   * @param {HTMLElement} el - The trigger element associated with the caption\n   * @param {Number} index - The index of the caption\n   * @returns {void}\n   */\n  const addCaption = (config, containerEl, imageEl, el, index) => {\n    const getCaptionData = triggerEl => {\n      const {\n        captionsAttribute,\n        captionsSelector,\n        captionsIdAttribute = 'data-caption-id'\n      } = config;\n\n      // Check for an ID reference on the trigger element\n      // This allows the caption to be anywhere on the page\n      const CAPTION_ID = triggerEl.getAttribute(captionsIdAttribute);\n      if (CAPTION_ID) {\n        const CAPTION_EL = document.getElementById(CAPTION_ID);\n        if (CAPTION_EL) {\n          return CAPTION_EL.innerHTML;\n        }\n      }\n\n      // Check for a direct caption attribute on the trigger element\n      const DIRECT_CAPTION = triggerEl.getAttribute(captionsAttribute);\n      if (DIRECT_CAPTION) {\n        return DIRECT_CAPTION;\n      }\n\n      // Query for a selector inside the trigger element\n      if (captionsSelector !== 'self') {\n        const CAPTION_EL = triggerEl.querySelector(captionsSelector);\n        if (CAPTION_EL) {\n          // Prefer a direct attribute on the found element, otherwise use its content\n          return CAPTION_EL.getAttribute(captionsAttribute) || CAPTION_EL.innerHTML;\n        }\n      }\n      return null;\n    };\n    const CAPTION_DATA = getCaptionData(el);\n    if (CAPTION_DATA) {\n      const CAPTION_CONTAINER = document.createElement('div');\n      const CAPTION_ID = `parvus__caption-${index}`;\n      CAPTION_CONTAINER.className = 'parvus__caption';\n      CAPTION_CONTAINER.id = CAPTION_ID;\n      CAPTION_CONTAINER.innerHTML = `<p>${CAPTION_DATA}</p>`;\n      containerEl.appendChild(CAPTION_CONTAINER);\n      imageEl.setAttribute('aria-describedby', CAPTION_ID);\n    }\n  };\n\n  /**\n   * Add copyright to the image container element\n   *\n   * @param {Object} config - Configuration object\n   * @param {HTMLElement} imageContainer - The image container element (parvus__content) to which the copyright will be added\n   * @param {HTMLElement} imageEl - The image the copyright is linked to\n   * @param {HTMLElement} el - The trigger element associated with the copyright\n   * @param {Number} index - The index of the copyright\n   * @returns {void}\n   */\n  const addCopyright = (config, imageContainer, imageEl, el, index) => {\n    const getCopyrightData = triggerEl => {\n      const {\n        copyrightAttribute,\n        copyrightSelector,\n        copyrightIdAttribute = 'data-copyright-id'\n      } = config;\n\n      // Check for an ID reference on the trigger element\n      // This allows the copyright to be anywhere on the page\n      const COPYRIGHT_ID = triggerEl.getAttribute(copyrightIdAttribute);\n      if (COPYRIGHT_ID) {\n        const COPYRIGHT_EL = document.getElementById(COPYRIGHT_ID);\n        if (COPYRIGHT_EL) {\n          return COPYRIGHT_EL.innerHTML;\n        }\n      }\n\n      // Check for a direct copyright attribute on the trigger element\n      const DIRECT_COPYRIGHT = triggerEl.getAttribute(copyrightAttribute);\n      if (DIRECT_COPYRIGHT) {\n        return DIRECT_COPYRIGHT;\n      }\n\n      // Query for a selector inside the trigger element\n      if (copyrightSelector !== 'self') {\n        const COPYRIGHT_EL = triggerEl.querySelector(copyrightSelector);\n        if (COPYRIGHT_EL) {\n          // Prefer a direct attribute on the found element, otherwise use its content\n          return COPYRIGHT_EL.getAttribute(copyrightAttribute) || COPYRIGHT_EL.innerHTML;\n        }\n      }\n      return null;\n    };\n    const COPYRIGHT_DATA = getCopyrightData(el);\n    if (COPYRIGHT_DATA) {\n      const COPYRIGHT_CONTAINER = document.createElement('div');\n      const COPYRIGHT_ID = `parvus__copyright-${index}`;\n      COPYRIGHT_CONTAINER.className = 'parvus__copyright';\n      COPYRIGHT_CONTAINER.id = COPYRIGHT_ID;\n      COPYRIGHT_CONTAINER.innerHTML = `<small>${COPYRIGHT_DATA}</small>`;\n      imageContainer.appendChild(COPYRIGHT_CONTAINER);\n\n      // If image already has aria-describedby (from caption), append copyright ID\n      const existingAriaDescribedby = imageEl.getAttribute('aria-describedby');\n      if (existingAriaDescribedby) {\n        imageEl.setAttribute('aria-describedby', `${existingAriaDescribedby} ${COPYRIGHT_ID}`);\n      } else {\n        imageEl.setAttribute('aria-describedby', COPYRIGHT_ID);\n      }\n    }\n  };\n\n  /**\n   * Create image\n   *\n   * @param {Object} state - The application state\n   * @param {HTMLElement} el - The trigger element\n   * @param {Number} index - The index\n   * @param {Function} callback - Callback function\n   * @returns {void}\n   */\n  const createImage = (state, el, index, callback) => {\n    const {\n      contentElements,\n      sliderElements\n    } = state.GROUPS[state.activeGroup];\n    if (contentElements[index] !== undefined) {\n      if (callback && typeof callback === 'function') {\n        callback();\n      }\n      return;\n    }\n    const CONTENT_CONTAINER_EL = sliderElements[index].querySelector('div');\n    const IMAGE = new Image();\n    const IMAGE_CONTAINER = document.createElement('div');\n    const THUMBNAIL = el.querySelector('img');\n    const LOADING_INDICATOR = document.createElement('div');\n    IMAGE_CONTAINER.className = 'parvus__content';\n\n    // Create loading indicator\n    LOADING_INDICATOR.className = 'parvus__loader';\n    LOADING_INDICATOR.setAttribute('role', 'progressbar');\n    LOADING_INDICATOR.setAttribute('aria-label', state.config.l10n.lightboxLoadingIndicatorLabel);\n\n    // Add loading indicator to content container\n    CONTENT_CONTAINER_EL.appendChild(LOADING_INDICATOR);\n    const checkImagePromise = new Promise((resolve, reject) => {\n      IMAGE.onload = () => resolve(IMAGE);\n      IMAGE.onerror = error => reject(error);\n    });\n    checkImagePromise.then(loadedImage => {\n      loadedImage.style.opacity = 0;\n      IMAGE_CONTAINER.appendChild(loadedImage);\n\n      // Add copyright if available (inside IMAGE_CONTAINER)\n      if (state.config.copyright) {\n        addCopyright(state.config, IMAGE_CONTAINER, IMAGE, el, index);\n      }\n      CONTENT_CONTAINER_EL.appendChild(IMAGE_CONTAINER);\n\n      // Add caption if available\n      if (state.config.captions) {\n        addCaption(state.config, CONTENT_CONTAINER_EL, IMAGE, el, index);\n      }\n      contentElements[index] = loadedImage;\n\n      // Set image width and height\n      loadedImage.setAttribute('width', loadedImage.naturalWidth);\n      loadedImage.setAttribute('height', loadedImage.naturalHeight);\n\n      // Set image dimension\n      setImageDimension(sliderElements[index], loadedImage);\n    }).catch(() => {\n      const ERROR_CONTAINER = document.createElement('div');\n      ERROR_CONTAINER.classList.add('parvus__content');\n      ERROR_CONTAINER.classList.add('parvus__content--error');\n      ERROR_CONTAINER.textContent = state.config.l10n.lightboxLoadingError;\n      CONTENT_CONTAINER_EL.appendChild(ERROR_CONTAINER);\n      contentElements[index] = ERROR_CONTAINER;\n    }).finally(() => {\n      CONTENT_CONTAINER_EL.removeChild(LOADING_INDICATOR);\n      if (callback && typeof callback === 'function') {\n        callback();\n      }\n    });\n\n    // Add `sizes` attribute\n    if (el.hasAttribute('data-sizes') && el.getAttribute('data-sizes') !== '') {\n      IMAGE.setAttribute('sizes', el.getAttribute('data-sizes'));\n    }\n\n    // Add `srcset` attribute\n    if (el.hasAttribute('data-srcset') && el.getAttribute('data-srcset') !== '') {\n      IMAGE.setAttribute('srcset', el.getAttribute('data-srcset'));\n    }\n\n    // Add `src` attribute\n    if (el.tagName === 'A') {\n      IMAGE.setAttribute('src', el.href);\n    } else {\n      IMAGE.setAttribute('src', el.getAttribute('data-target'));\n    }\n\n    // `alt` attribute\n    if (THUMBNAIL && THUMBNAIL.hasAttribute('alt') && THUMBNAIL.getAttribute('alt') !== '') {\n      IMAGE.alt = THUMBNAIL.alt;\n    } else if (el.hasAttribute('data-alt') && el.getAttribute('data-alt') !== '') {\n      IMAGE.alt = el.getAttribute('data-alt');\n    } else {\n      IMAGE.alt = '';\n    }\n  };\n\n  /**\n   * Load Image\n   *\n   * @param {Object} state - The application state\n   * @param {Number} index - The index of the image to load\n   * @param {Boolean} animate - Whether to animate the image\n   * @returns {void}\n   */\n  const loadImage = (state, index, animate) => {\n    const IMAGE = state.GROUPS[state.activeGroup].contentElements[index];\n    if (IMAGE && IMAGE.tagName === 'IMG') {\n      const THUMBNAIL = state.GROUPS[state.activeGroup].triggerElements[index];\n      if (animate && document.startViewTransition) {\n        THUMBNAIL.style.viewTransitionName = 'lightboximage';\n        const transition = document.startViewTransition(() => {\n          IMAGE.style.opacity = '';\n          THUMBNAIL.style.viewTransitionName = null;\n          IMAGE.style.viewTransitionName = 'lightboximage';\n        });\n        transition.finished.finally(() => {\n          IMAGE.style.viewTransitionName = null;\n        });\n      } else {\n        IMAGE.style.opacity = '';\n      }\n    } else {\n      IMAGE.style.opacity = '';\n    }\n  };\n\n  /**\n   * Set image dimension\n   *\n   * @param {HTMLElement} slideEl - The slide element\n   * @param {HTMLElement} contentEl - The content element\n   * @returns {void}\n   */\n  const setImageDimension = (slideEl, contentEl) => {\n    if (contentEl.tagName !== 'IMG') {\n      return;\n    }\n    const SRC_HEIGHT = contentEl.getAttribute('height');\n    const SRC_WIDTH = contentEl.getAttribute('width');\n    if (!SRC_HEIGHT || !SRC_WIDTH) {\n      return;\n    }\n    const SLIDE_EL_STYLES = getComputedStyle(slideEl);\n    const HORIZONTAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingLeft) + parseFloat(SLIDE_EL_STYLES.paddingRight);\n    const VERTICAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingTop) + parseFloat(SLIDE_EL_STYLES.paddingBottom);\n    const CAPTION_EL = slideEl.querySelector('.parvus__caption');\n    const CAPTION_HEIGHT = CAPTION_EL ? CAPTION_EL.getBoundingClientRect().height : 0;\n    const MAX_WIDTH = slideEl.offsetWidth - HORIZONTAL_PADDING;\n    const MAX_HEIGHT = slideEl.offsetHeight - VERTICAL_PADDING - CAPTION_HEIGHT;\n    const RATIO = Math.min(MAX_WIDTH / SRC_WIDTH || 0, MAX_HEIGHT / SRC_HEIGHT || 0);\n    const NEW_WIDTH = SRC_WIDTH * RATIO;\n    const NEW_HEIGHT = SRC_HEIGHT * RATIO;\n    const USE_ORIGINAL_SIZE = SRC_WIDTH <= MAX_WIDTH && SRC_HEIGHT <= MAX_HEIGHT;\n    contentEl.style.width = USE_ORIGINAL_SIZE ? '' : `${NEW_WIDTH}px`;\n    contentEl.style.height = USE_ORIGINAL_SIZE ? '' : `${NEW_HEIGHT}px`;\n  };\n\n  /**\n   * Create resize handler\n   *\n   * @param {Object} state - The application state\n   * @param {Function} updateOffset - Update offset function\n   * @returns {Function} Resize event handler\n   */\n  const createResizeHandler = (state, updateOffset) => {\n    return () => {\n      if (!state.resizeTicking) {\n        state.resizeTicking = true;\n        window.requestAnimationFrame(() => {\n          state.GROUPS[state.activeGroup].sliderElements.forEach((slide, index) => {\n            setImageDimension(slide, state.GROUPS[state.activeGroup].contentElements[index]);\n          });\n          updateOffset();\n          state.resizeTicking = false;\n        });\n      }\n    };\n  };\n\n  // Helper modules\n\n  /**\n   * Parvus Lightbox\n   *\n   * @param {Object} userOptions - User configuration options\n   * @returns {Object} Parvus instance\n   */\n  function Parvus(userOptions) {\n    const BROWSER_WINDOW = window;\n    const STATE = new ParvusState();\n    const MOTIONQUERY = BROWSER_WINDOW.matchMedia('(prefers-reduced-motion)');\n    const PLUGIN_MANAGER = new PluginManager();\n\n    // Event handlers will be created after actions are defined\n    let keydownHandler, clickHandler, pointerdownHandler, pointermoveHandler, pointerupHandler, resizeHandler;\n\n    /**\n     * Click event handler to trigger Parvus\n     *\n     * @param {Event} event - The click event object\n     */\n    const triggerParvus = function triggerParvus(event) {\n      event.preventDefault();\n      open(this);\n    };\n\n    /**\n     * Add an element\n     *\n     * @param {HTMLElement} el - The element to be added\n     */\n    const add = el => {\n      // Check element type and attributes\n      const IS_VALID_LINK = el.tagName === 'A' && el.hasAttribute('href');\n      const IS_VALID_BUTTON = el.tagName === 'BUTTON' && el.hasAttribute('data-target');\n      if (!IS_VALID_LINK && !IS_VALID_BUTTON) {\n        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.');\n      }\n\n      // Check if the lightbox already exists\n      if (!STATE.lightbox) {\n        createLightbox(STATE);\n\n        // Execute afterInit hook when lightbox is first created\n        PLUGIN_MANAGER.executeHook('afterInit', {\n          state: STATE\n        });\n      }\n      STATE.newGroup = getGroup(STATE, el);\n      if (!STATE.GROUPS[STATE.newGroup]) {\n        STATE.GROUPS[STATE.newGroup] = structuredClone(STATE.GROUP_ATTRIBUTES);\n      }\n      if (STATE.GROUPS[STATE.newGroup].triggerElements.includes(el)) {\n        throw new Error('Ups, element already added.');\n      }\n      STATE.GROUPS[STATE.newGroup].triggerElements.push(el);\n      if (STATE.config.zoomIndicator) {\n        addZoomIndicator(el, STATE.config);\n      }\n      el.classList.add('parvus-trigger');\n      el.addEventListener('click', triggerParvus);\n      if (isOpen() && STATE.newGroup === STATE.activeGroup) {\n        const EL_INDEX = STATE.GROUPS[STATE.newGroup].triggerElements.indexOf(el);\n        createSlide(STATE, EL_INDEX);\n        createImage(STATE, el, EL_INDEX, () => {\n          loadImage(STATE, EL_INDEX);\n        });\n        updateAttributes(STATE);\n        updateSliderNavigationStatus(STATE);\n        updateCounter(STATE);\n      }\n    };\n\n    /**\n     * Remove an element\n     *\n     * @param {HTMLElement} el - The element to be removed\n     */\n    const remove = el => {\n      if (!el || !el.hasAttribute('data-group')) {\n        return;\n      }\n      const EL_GROUP = getGroup(STATE, el);\n      const GROUP = STATE.GROUPS[EL_GROUP];\n\n      // Check if element exists\n      if (!GROUP) {\n        return;\n      }\n      const EL_INDEX = GROUP.triggerElements.indexOf(el);\n      if (EL_INDEX === -1) {\n        return;\n      }\n      const IS_CURRENT_EL = isOpen() && EL_GROUP === STATE.activeGroup && EL_INDEX === STATE.currentIndex;\n\n      // Remove group data\n      if (GROUP.contentElements[EL_INDEX]) {\n        const content = GROUP.contentElements[EL_INDEX];\n        if (content.tagName === 'IMG') {\n          content.src = '';\n          content.srcset = '';\n        }\n      }\n\n      // Remove DOM element\n      const sliderElement = GROUP.sliderElements[EL_INDEX];\n      if (sliderElement && sliderElement.parentNode) {\n        sliderElement.parentNode.removeChild(sliderElement);\n      }\n\n      // Remove all array elements\n      GROUP.triggerElements.splice(EL_INDEX, 1);\n      GROUP.sliderElements.splice(EL_INDEX, 1);\n      GROUP.contentElements.splice(EL_INDEX, 1);\n      if (STATE.config.zoomIndicator) {\n        removeZoomIndicator(el);\n      }\n      if (isOpen() && EL_GROUP === STATE.activeGroup) {\n        if (IS_CURRENT_EL) {\n          if (GROUP.triggerElements.length === 0) {\n            close();\n          } else if (STATE.currentIndex >= GROUP.triggerElements.length) {\n            select(GROUP.triggerElements.length - 1);\n          } else {\n            updateAttributes(STATE);\n            updateSliderNavigationStatus(STATE);\n            updateCounter(STATE);\n          }\n        } else if (EL_INDEX < STATE.currentIndex) {\n          STATE.currentIndex--;\n          updateAttributes(STATE);\n          updateSliderNavigationStatus(STATE);\n          updateCounter(STATE);\n        } else {\n          updateAttributes(STATE);\n          updateSliderNavigationStatus(STATE);\n          updateCounter(STATE);\n        }\n      }\n\n      // Unbind click event handler\n      el.removeEventListener('click', triggerParvus);\n      el.classList.remove('parvus-trigger');\n    };\n\n    /**\n     * Open Parvus\n     *\n     * @param {HTMLElement} el\n     */\n    const open = el => {\n      if (!STATE.lightbox || !el || !el.classList.contains('parvus-trigger') || isOpen()) {\n        return;\n      }\n      STATE.activeGroup = getGroup(STATE, el);\n      const GROUP = STATE.GROUPS[STATE.activeGroup];\n      const EL_INDEX = GROUP.triggerElements.indexOf(el);\n      if (EL_INDEX === -1) {\n        throw new Error('Ups, element not found in group.');\n      }\n      STATE.currentIndex = EL_INDEX;\n      history.pushState({\n        parvus: 'close'\n      }, 'Image', window.location.href);\n      bindEvents();\n      if (STATE.config.hideScrollbar) {\n        document.body.style.marginInlineEnd = `${getScrollbarWidth()}px`;\n        document.body.style.overflow = 'hidden';\n      }\n      STATE.lightbox.classList.add('parvus--is-opening');\n      STATE.lightbox.showModal();\n      createSlider(STATE);\n      createSlide(STATE, STATE.currentIndex);\n      updateOffset(STATE);\n      updateAttributes(STATE);\n      updateSliderNavigationStatus(STATE);\n      updateCounter(STATE);\n      loadSlide(STATE, STATE.currentIndex);\n      createImage(STATE, el, STATE.currentIndex, () => {\n        loadImage(STATE, STATE.currentIndex, true);\n        STATE.lightbox.classList.remove('parvus--is-opening');\n        GROUP.slider.classList.add('parvus__slider--animate');\n      });\n      preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex + 1);\n      preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex - 1);\n\n      // Execute afterOpen hook\n      PLUGIN_MANAGER.executeHook('afterOpen', {\n        element: el,\n        state: STATE\n      });\n\n      // Create and dispatch a new event\n      dispatchCustomEvent(STATE.lightbox, 'open');\n    };\n\n    /**\n     * Close Parvus\n     */\n    const close = () => {\n      if (!isOpen()) {\n        return;\n      }\n      const IMAGE = STATE.GROUPS[STATE.activeGroup].contentElements[STATE.currentIndex];\n      const THUMBNAIL = STATE.GROUPS[STATE.activeGroup].triggerElements[STATE.currentIndex];\n      unbindEvents();\n      STATE.clearDrag();\n      if (history.state?.parvus === 'close') {\n        history.back();\n      }\n      STATE.lightbox.classList.add('parvus--is-closing');\n      const transitionendHandler = () => {\n        // Reset the image zoom (if ESC was pressed or went back in the browser history)\n        // after the ViewTransition (otherwise it looks bad)\n        if (STATE.isPinching) {\n          resetZoom(STATE, IMAGE);\n        }\n        leaveSlide(STATE, STATE.currentIndex);\n        STATE.lightbox.close();\n        STATE.lightbox.classList.remove('parvus--is-closing');\n        STATE.lightbox.classList.remove('parvus--is-vertical-closing');\n        STATE.GROUPS[STATE.activeGroup].slider.remove();\n        STATE.GROUPS[STATE.activeGroup].slider = null;\n        STATE.GROUPS[STATE.activeGroup].sliderElements = [];\n        STATE.GROUPS[STATE.activeGroup].contentElements = [];\n        STATE.counter.removeAttribute('aria-hidden');\n        STATE.previousButton.removeAttribute('aria-hidden');\n        STATE.previousButton.removeAttribute('aria-disabled');\n        STATE.nextButton.removeAttribute('aria-hidden');\n        STATE.nextButton.removeAttribute('aria-disabled');\n        if (STATE.config.hideScrollbar) {\n          document.body.style.marginInlineEnd = '';\n          document.body.style.overflow = '';\n        }\n\n        // Execute afterClose hook\n        PLUGIN_MANAGER.executeHook('afterClose', {\n          state: STATE\n        });\n      };\n      if (IMAGE && IMAGE.tagName === 'IMG') {\n        if (document.startViewTransition) {\n          IMAGE.style.viewTransitionName = 'lightboximage';\n          const transition = document.startViewTransition(() => {\n            IMAGE.style.opacity = '0';\n            IMAGE.style.viewTransitionName = null;\n            THUMBNAIL.style.viewTransitionName = 'lightboximage';\n          });\n          transition.finished.finally(() => {\n            transitionendHandler();\n            THUMBNAIL.style.viewTransitionName = null;\n          });\n        } else {\n          IMAGE.style.opacity = '0';\n          requestAnimationFrame(transitionendHandler);\n        }\n      } else {\n        transitionendHandler();\n      }\n    };\n\n    /**\n     * Select a specific slide by index\n     *\n     * @param {number} index - Index of the slide to select\n     */\n    const select = index => {\n      if (!isOpen()) {\n        throw new Error(\"Oops, I'm closed.\");\n      }\n      if (typeof index !== 'number' || isNaN(index)) {\n        throw new Error('Oops, no slide specified.');\n      }\n      const GROUP = STATE.GROUPS[STATE.activeGroup];\n      const triggerElements = GROUP.triggerElements;\n      if (index === STATE.currentIndex) {\n        throw new Error(`Oops, slide ${index} is already selected.`);\n      }\n      if (index < 0 || index >= triggerElements.length) {\n        throw new Error(`Oops, I can't find slide ${index}.`);\n      }\n      const OLD_INDEX = STATE.currentIndex;\n      STATE.currentIndex = index;\n      if (GROUP.sliderElements[index]) {\n        loadSlide(STATE, index);\n      } else {\n        createSlide(STATE, index);\n        createImage(STATE, GROUP.triggerElements[index], index, () => {\n          loadImage(STATE, index);\n        });\n        loadSlide(STATE, index);\n      }\n      updateOffset(STATE);\n      updateSliderNavigationStatus(STATE);\n      updateCounter(STATE);\n\n      // Execute slideChange hook\n      PLUGIN_MANAGER.executeHook('slideChange', {\n        index,\n        oldIndex: OLD_INDEX,\n        state: STATE\n      });\n      if (index < OLD_INDEX) {\n        preload(STATE, createSlide, createImage, loadImage, index - 1);\n      } else {\n        preload(STATE, createSlide, createImage, loadImage, index + 1);\n      }\n      leaveSlide(STATE, OLD_INDEX);\n\n      // Create and dispatch a new event\n      dispatchCustomEvent(STATE.lightbox, 'select');\n    };\n\n    /**\n     * Select the previous slide\n     */\n    const previous = () => {\n      if (STATE.currentIndex > 0) {\n        select(STATE.currentIndex - 1);\n      }\n    };\n\n    /**\n     * Select the next slide\n     */\n    const next = () => {\n      const {\n        triggerElements\n      } = STATE.GROUPS[STATE.activeGroup];\n      if (STATE.currentIndex < triggerElements.length - 1) {\n        select(STATE.currentIndex + 1);\n      }\n    };\n\n    /**\n     * Bind specified events\n     */\n    const bindEvents = () => {\n      const actions = {\n        close,\n        previous,\n        next,\n        updateOffset: () => updateOffset(STATE)\n      };\n\n      // Create handlers with state and actions\n      keydownHandler = createKeydownHandler(STATE, actions);\n      clickHandler = createClickHandler(STATE, actions);\n      resizeHandler = createResizeHandler(STATE, () => updateOffset(STATE));\n      const updateAfterDragHandler = () => updateAfterDrag(STATE, actions);\n      const pinchZoomHandler = img => pinchZoom(STATE, img);\n      const doSwipeHandler = () => doSwipe(STATE);\n      const resetZoomHandler = img => resetZoom(STATE, img);\n      pointerdownHandler = createPointerdownHandler(STATE);\n      pointermoveHandler = createPointermoveHandler(STATE, pinchZoomHandler, doSwipeHandler);\n      pointerupHandler = createPointerupHandler(STATE, resetZoomHandler, updateAfterDragHandler);\n      BROWSER_WINDOW.addEventListener('keydown', keydownHandler);\n      BROWSER_WINDOW.addEventListener('resize', resizeHandler);\n\n      // Popstate event\n      BROWSER_WINDOW.addEventListener('popstate', close);\n\n      // Check for any OS level changes to the prefers reduced motion preference\n      MOTIONQUERY.addEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY));\n\n      // Click event\n      STATE.lightbox.addEventListener('click', clickHandler);\n\n      // Pointer events\n      STATE.lightbox.addEventListener('pointerdown', pointerdownHandler, {\n        passive: false\n      });\n      STATE.lightbox.addEventListener('pointerup', pointerupHandler, {\n        passive: true\n      });\n      STATE.lightbox.addEventListener('pointermove', pointermoveHandler, {\n        passive: false\n      });\n    };\n\n    /**\n     * Unbind specified events\n     */\n    const unbindEvents = () => {\n      BROWSER_WINDOW.removeEventListener('keydown', keydownHandler);\n      BROWSER_WINDOW.removeEventListener('resize', resizeHandler);\n\n      // Popstate event\n      BROWSER_WINDOW.removeEventListener('popstate', close);\n\n      // Check for any OS level changes to the prefers reduced motion preference\n      MOTIONQUERY.removeEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY));\n\n      // Click event\n      STATE.lightbox.removeEventListener('click', clickHandler);\n\n      // Pointer events\n      STATE.lightbox.removeEventListener('pointerdown', pointerdownHandler);\n      STATE.lightbox.removeEventListener('pointerup', pointerupHandler);\n      STATE.lightbox.removeEventListener('pointermove', pointermoveHandler);\n    };\n\n    /**\n     * Destroy Parvus\n     */\n    const destroy = () => {\n      if (!STATE.lightbox) {\n        return;\n      }\n      if (isOpen()) {\n        close();\n      }\n\n      // Add setTimeout to ensure all possible close transitions are completed\n      setTimeout(() => {\n        unbindEvents();\n\n        // Remove all registered event listeners for custom events\n        const eventTypes = ['open', 'close', 'select', 'destroy'];\n        eventTypes.forEach(eventType => {\n          const listeners = STATE.lightbox._listeners?.[eventType] || [];\n          listeners.forEach(listener => {\n            STATE.lightbox.removeEventListener(eventType, listener);\n          });\n        });\n\n        // Remove event listeners from trigger elements\n        const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll('.parvus-trigger');\n        LIGHTBOX_TRIGGER_ELS.forEach(el => {\n          el.removeEventListener('click', triggerParvus);\n          el.classList.remove('parvus-trigger');\n          if (STATE.config.zoomIndicator) {\n            removeZoomIndicator(el);\n          }\n          if (el.dataset.group) {\n            delete el.dataset.group;\n          }\n        });\n\n        // Create and dispatch a new event\n        dispatchCustomEvent(STATE.lightbox, 'destroy');\n        STATE.lightbox.remove();\n\n        // Remove references\n        STATE.lightbox = null;\n        STATE.lightboxOverlay = null;\n        STATE.toolbar = null;\n        STATE.toolbarLeft = null;\n        STATE.toolbarRight = null;\n        STATE.controls = null;\n        STATE.previousButton = null;\n        STATE.nextButton = null;\n        STATE.closeButton = null;\n        STATE.counter = null;\n\n        // Remove group data\n        Object.keys(STATE.GROUPS).forEach(groupKey => {\n          const group = STATE.GROUPS[groupKey];\n          if (group && group.contentElements) {\n            group.contentElements.forEach(content => {\n              if (content && content.tagName === 'IMG') {\n                content.src = '';\n                content.srcset = '';\n              }\n            });\n          }\n          delete STATE.GROUPS[groupKey];\n        });\n\n        // Reset variables\n        STATE.groupIdCounter = 0;\n        STATE.newGroup = null;\n        STATE.activeGroup = null;\n        STATE.currentIndex = 0;\n      }, 1000);\n    };\n\n    /**\n     * Check if Parvus is open\n     *\n     * @returns {boolean} - True if Parvus is open, otherwise false\n     */\n    const isOpen = () => {\n      return STATE.lightbox?.hasAttribute('open');\n    };\n\n    /**\n     * Get the current index\n     *\n     * @returns {number} - The current index\n     */\n    const getCurrentIndex = () => {\n      return STATE.currentIndex;\n    };\n\n    /**\n     * Bind a specific event listener\n     *\n     * @param {String} eventName - The name of the event to bind\n     * @param {Function} callback - The callback function\n     */\n    const on$1 = (eventName, callback) => {\n      on(STATE.lightbox, eventName, callback);\n    };\n\n    /**\n     * Unbind a specific event listener\n     *\n     * @param {String} eventName - The name of the event to unbind\n     * @param {Function} callback - The callback function\n     */\n    const off$1 = (eventName, callback) => {\n      off(STATE.lightbox, eventName, callback);\n    };\n\n    /**\n     * Use a plugin\n     *\n     * @param {Object} plugin - Plugin object\n     * @param {Object} options - Plugin options\n     */\n    const use = (plugin, options = {}) => {\n      PLUGIN_MANAGER.register(plugin, options);\n    };\n\n    /**\n     * Add a hook callback\n     *\n     * @param {String} hookName - Hook name\n     * @param {Function} callback - Callback function\n     */\n    const addHook = (hookName, callback) => {\n      PLUGIN_MANAGER.addHook(hookName, callback);\n    };\n\n    /**\n     * Get registered plugins\n     *\n     * @returns {Array} Array of plugin names\n     */\n    const getPlugins = () => {\n      return PLUGIN_MANAGER.getPlugins();\n    };\n\n    /**\n     * Init\n     */\n    const init = () => {\n      // Merge user options into defaults\n      STATE.config = mergeOptions(userOptions);\n      reducedMotionCheck(STATE, MOTIONQUERY);\n\n      // Install plugins with context\n      const pluginContext = {\n        state: STATE,\n        on: on,\n        addHook: PLUGIN_MANAGER.addHook.bind(PLUGIN_MANAGER),\n        config: STATE.config\n      };\n      PLUGIN_MANAGER.install(pluginContext);\n      if (STATE.config.gallerySelector !== null) {\n        // Get a list of all `gallerySelector` elements within the document\n        const GALLERY_ELS = document.querySelectorAll(STATE.config.gallerySelector);\n\n        // Execute a few things once per element\n        GALLERY_ELS.forEach((galleryEl, index) => {\n          const GALLERY_INDEX = index;\n          // Get a list of all `selector` elements within the `gallerySelector`\n          const LIGHTBOX_TRIGGER_GALLERY_ELS = galleryEl.querySelectorAll(STATE.config.selector);\n\n          // Execute a few things once per element\n          LIGHTBOX_TRIGGER_GALLERY_ELS.forEach(lightboxTriggerEl => {\n            lightboxTriggerEl.setAttribute('data-group', `parvus-gallery-${GALLERY_INDEX}`);\n            add(lightboxTriggerEl);\n          });\n        });\n      }\n\n      // Get a list of all `selector` elements outside or without the `gallerySelector`\n      const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(`${STATE.config.selector}:not(.parvus-trigger)`);\n      LIGHTBOX_TRIGGER_ELS.forEach(add);\n    };\n    init();\n    return {\n      init,\n      open,\n      close,\n      select,\n      previous,\n      next,\n      currentIndex: getCurrentIndex,\n      add,\n      remove,\n      destroy,\n      isOpen,\n      on: on$1,\n      off: off$1,\n      use,\n      addHook,\n      getPlugins\n    };\n  }\n\n  return Parvus;\n\n}));\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"parvus\",\n  \"type\": \"module\",\n  \"version\": \"3.1.0\",\n  \"description\": \"An open source, dependency free image lightbox with the goal of being accessible.\",\n  \"main\": \"./dist/js/parvus.js\",\n  \"module\": \"./dist/js/parvus.esm.js\",\n  \"style\": \"./dist/css/parvus.css\",\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.29.0\",\n    \"@babel/preset-env\": \"^7.29.2\",\n    \"@rollup/plugin-babel\": \"^7.0.0\",\n    \"@rollup/plugin-commonjs\": \"^29.0.2\",\n    \"@rollup/plugin-node-resolve\": \"^16.0.3\",\n    \"@rollup/plugin-terser\": \"^1.0.0\",\n    \"core-js\": \"^3.49.0\",\n    \"postcss\": \"^8.5.10\",\n    \"rollup\": \"^4.60.2\",\n    \"rollup-plugin-license\": \"^3.7.1\",\n    \"rollup-plugin-postcss\": \"^4.0.2\",\n    \"sass\": \"^1.99.0\",\n    \"standard\": \"^17.1.2\",\n    \"stylelint\": \"^17.8.0\",\n    \"stylelint-config-standard-scss\": \"^17.0.0\",\n    \"stylelint-scss\": \"^7.0.0\",\n    \"stylelint-use-logical\": \"^2.1.3\"\n  },\n  \"browserslist\": [\n    \"last 2 versions and > 1% and not dead\"\n  ],\n  \"standard\": {\n    \"globals\": [\n      \"Image\",\n      \"history\",\n      \"CustomEvent\",\n      \"requestAnimationFrame\",\n      \"getComputedStyle\"\n    ]\n  },\n  \"scripts\": {\n    \"build\": \"npm run testCss && npm run buildCss && npm run testJs && npm run buildJs\",\n    \"buildCss\": \"rollup -c --environment BUILDCSS --bundleConfigAsCjs\",\n    \"buildJs\": \"rollup -c --environment BUILDJS --bundleConfigAsCjs\",\n    \"buildWatch\": \"npm run buildWatchJs && npm run buildWatchCss\",\n    \"buildWatchCss\": \"rollup -c -w --environment BUILDCSS --bundleConfigAsCjs\",\n    \"buildWatchJs\": \"rollup -c -w --environment BUILDJS --bundleConfigAsCjs\",\n    \"testCss\": \"stylelint \\\"src/scss/parvus.scss\\\"\",\n    \"testJs\": \"standard \\\"src/js/parvus.js\\\"\",\n    \"test\": \"npm run testCss && npm run testJs\"\n  },\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/js/parvus.esm.js\",\n      \"require\": \"./dist/js/parvus.js\"\n    },\n    \"./src/scss/*\": \"./src/scss/*.scss\",\n    \"./src/l10n/*\": \"./src/l10n/*.js\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git://github.com/deoostfrees/parvus.git\"\n  },\n  \"keywords\": [\n    \"lightbox\",\n    \"accessible\",\n    \"a11y\",\n    \"javascript\",\n    \"vanilla\",\n    \"scss\",\n    \"css\"\n  ],\n  \"author\": \"Benjamin de Oostfrees\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/deoostfrees/parvus/issues\"\n  },\n  \"homepage\": \"https://github.com/deoostfrees/parvus\"\n}\n"
  },
  {
    "path": "rollup.config.js",
    "content": "import resolve from '@rollup/plugin-node-resolve'\nimport commonjs from '@rollup/plugin-commonjs'\nimport terser from '@rollup/plugin-terser'\nimport postcss from 'rollup-plugin-postcss'\nimport babel from '@rollup/plugin-babel'\nimport license from 'rollup-plugin-license'\n\nimport pkg from './package.json'\n\nconst bannerContent = `\n  Parvus\n\n  @author ${pkg.author}\n  @version ${pkg.version}\n  @url ${pkg.homepage}\n\n  ${pkg.license} license`\n\nconst rollupBuilds = []\n\n/**\n * Build JavaScript\n *\n */\nif (process.env.BUILDJS) {\n  rollupBuilds.push({\n    input: './src/js/parvus.js',\n    output: [\n      {\n        format: 'umd',\n        file: './dist/js/parvus.js',\n        name: 'Parvus'\n      },\n      {\n        format: 'es',\n        file: './dist/js/parvus.esm.js',\n        name: 'Parvus'\n      },\n      {\n        format: 'umd',\n        file: './dist/js/parvus.min.js',\n        name: 'Parvus',\n        plugins: [\n          terser(),\n          license({\n            banner: {\n              content: bannerContent\n            }\n          })\n        ]\n      },\n      {\n        format: 'es',\n        file: './dist/js/parvus.esm.min.js',\n        name: 'Parvus',\n        plugins: [\n          terser(),\n          license({\n            banner: {\n              content: bannerContent\n            }\n          })\n        ]\n      }\n    ],\n    plugins: [\n      resolve({\n        browser: true\n      }),\n      commonjs(),\n      babel({\n        babelHelpers: 'bundled',\n        exclude: 'node_modules/**',\n        presets: [\n          ['@babel/preset-env', {\n            corejs: 3.15,\n            useBuiltIns: 'entry'\n          }]\n        ]\n      }),\n      license({\n        banner: {\n          content: bannerContent\n        }\n      })\n    ],\n    watch: {\n      clearScreen: false\n    }\n  })\n}\n\n/**\n * Build CSS\n *\n */\nif (process.env.BUILDCSS) {\n  rollupBuilds.push(\n    {\n      input: './src/scss/parvus.scss',\n      output: [\n        {\n          file: './dist/css/parvus.css'\n        }\n      ],\n      plugins: [\n        resolve({\n          browser: true\n        }),\n        commonjs(),\n        postcss({\n          extract: true\n        }),\n        license({\n          banner: {\n            content: bannerContent\n          }\n        })\n      ],\n      watch: {\n        clearScreen: false\n      }\n    },\n    {\n      input: './src/scss/parvus.scss',\n      output: [\n        {\n          file: './dist/css/parvus.min.css'\n        }\n      ],\n      plugins: [\n        resolve({\n          browser: true\n        }),\n        commonjs(),\n        postcss({\n          extract: true,\n          minimize: true\n        }),\n        license({\n          banner: {\n            content: bannerContent\n          }\n        })\n      ],\n      watch: {\n        clearScreen: false\n      }\n    }\n  )\n}\n\nexport default rollupBuilds\n"
  },
  {
    "path": "src/js/core/config.js",
    "content": "import en from '../../l10n/en.js'\n\n/**\n * Default configuration options\n */\nexport const DEFAULT_OPTIONS = {\n  selector: '.lightbox',\n  gallerySelector: null,\n  zoomIndicator: true,\n  captions: true,\n  captionsSelector: 'self',\n  captionsAttribute: 'data-caption',\n  copyright: true,\n  copyrightSelector: 'self',\n  copyrightAttribute: 'data-copyright',\n  docClose: true,\n  swipeClose: true,\n  simulateTouch: true,\n  threshold: 50,\n  hideScrollbar: true,\n  lightboxIndicatorIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" stroke=\"currentColor\"><path d=\"M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3\"/></svg>',\n  previousButtonIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1\" stroke=\"currentColor\"><path stroke=\"none\" d=\"M0 0h24v24H0z\"/><polyline points=\"15 6 9 12 15 18\" /></svg>',\n  nextButtonIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1\" stroke=\"currentColor\"><path stroke=\"none\" d=\"M0 0h24v24H0z\"/><polyline points=\"9 6 15 12 9 18\" /></svg>',\n  closeButtonIcon: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1\" stroke=\"currentColor\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>',\n  l10n: en\n}\n\n/**\n * Merge default options with user-provided options\n *\n * @param {Object} userOptions - User-provided options\n * @returns {Object} - Merged options object\n */\nexport const mergeOptions = (userOptions) => {\n  const MERGED_OPTIONS = {\n    ...DEFAULT_OPTIONS,\n    ...userOptions\n  }\n\n  if (userOptions && userOptions.l10n) {\n    MERGED_OPTIONS.l10n = {\n      ...DEFAULT_OPTIONS.l10n,\n      ...userOptions.l10n\n    }\n  }\n\n  return MERGED_OPTIONS\n}\n"
  },
  {
    "path": "src/js/core/events.js",
    "content": "/**\n * Event System Module\n *\n * Handles custom event dispatching and listeners\n */\n\n/**\n * Dispatch a custom event\n *\n * @param {HTMLElement} lightbox - The lightbox element\n * @param {String} type - The type of the event to dispatch\n * @returns {void}\n */\nexport const dispatchCustomEvent = (lightbox, type) => {\n  const CUSTOM_EVENT = new CustomEvent(type, {\n    cancelable: true\n  })\n\n  lightbox.dispatchEvent(CUSTOM_EVENT)\n}\n\n/**\n * Bind a specific event listener\n *\n * @param {HTMLElement} lightbox - The lightbox element\n * @param {String} eventName - The name of the event to bind\n * @param {Function} callback - The callback function\n * @returns {void}\n */\nexport const on = (lightbox, eventName, callback) => {\n  if (lightbox) {\n    lightbox.addEventListener(eventName, callback)\n  }\n}\n\n/**\n * Unbind a specific event listener\n *\n * @param {HTMLElement} lightbox - The lightbox element\n * @param {String} eventName - The name of the event to unbind\n * @param {Function} callback - The callback function\n * @returns {void}\n */\nexport const off = (lightbox, eventName, callback) => {\n  if (lightbox) {\n    lightbox.removeEventListener(eventName, callback)\n  }\n}\n"
  },
  {
    "path": "src/js/core/navigation.js",
    "content": "/**\n * Navigation Module\n *\n * Handles slide navigation and transitions\n */\n\n/**\n * Update offset\n *\n * @param {Object} state - The application state\n * @returns {void}\n */\nexport const updateOffset = (state) => {\n  state.activeGroup = state.activeGroup !== null ? state.activeGroup : state.newGroup\n\n  state.offset = -state.currentIndex * state.lightbox.offsetWidth\n\n  state.GROUPS[state.activeGroup].slider.style.transform = `translate3d(${state.offset}px, 0, 0)`\n  state.offsetTmp = state.offset\n}\n\n/**\n * Load slide with the specified index\n *\n * @param {Object} state - The application state\n * @param {Number} index - The index of the slide to be loaded\n * @returns {void}\n */\nexport const loadSlide = (state, index) => {\n  state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'false')\n}\n\n/**\n * Leave slide\n *\n * @param {Object} state - The application state\n * @param {Number} index - The index of the slide to leave\n * @returns {void}\n */\nexport const leaveSlide = (state, index) => {\n  if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {\n    state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'true')\n  }\n}\n\n/**\n * Preload slide with the specified index\n *\n * @param {Object} state - The application state\n * @param {Function} createSlide - Create slide function\n * @param {Function} createImage - Create image function\n * @param {Function} loadImage - Load image function\n * @param {Number} index - The index of the slide to be preloaded\n * @returns {void}\n */\nexport const preload = (state, createSlide, createImage, loadImage, index) => {\n  if (index < 0 || index >= state.GROUPS[state.activeGroup].triggerElements.length || state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {\n    return\n  }\n\n  createSlide(state, index)\n  createImage(state, state.GROUPS[state.activeGroup].triggerElements[index], index, () => {\n    loadImage(state, index)\n  })\n}\n"
  },
  {
    "path": "src/js/core/plugins.js",
    "content": "/**\n * Plugin management for Parvus\n *\n * Provides a system for registering and managing plugins\n */\n\nexport class PluginManager {\n  constructor () {\n    this.plugins = []\n    this.hooks = {}\n    this.context = null\n    this.isInitialized = false\n  }\n\n  /**\n   * Register a plugin\n   *\n   * @param {Object} plugin - Plugin object with name and install function\n   * @param {Object} options - Plugin-specific options\n   */\n  register (plugin, options = {}) {\n    if (!plugin || typeof plugin.install !== 'function') {\n      throw new Error('Plugin must have an install function')\n    }\n\n    if (!plugin.name) {\n      throw new Error('Plugin must have a name')\n    }\n\n    // Check if plugin is already registered\n    const existingPlugin = this.plugins.find(p => p.name === plugin.name)\n    if (existingPlugin) {\n      console.warn(`Plugin \"${plugin.name}\" is already registered`)\n      return\n    }\n\n    this.plugins.push({ plugin, options })\n\n    // If already initialized, install immediately\n    if (this.isInitialized && this.context) {\n      this.installPlugin(plugin, options)\n    }\n  }\n\n  /**\n   * Install a single plugin\n   *\n   * @param {Object} plugin - Plugin object\n   * @param {Object} options - Plugin options\n   */\n  installPlugin (plugin, options) {\n    try {\n      plugin.install(this.context, options)\n\n      // If lightbox already exists, execute afterInit hook for this plugin immediately\n      if (this.context && this.context.state && this.context.state.lightbox) {\n        this.executeHook('afterInit', { state: this.context.state })\n      }\n    } catch (error) {\n      console.error(`Failed to install plugin \"${plugin.name}\":`, error)\n    }\n  }\n\n  /**\n   * Install all registered plugins\n   *\n   * @param {Object} context - Parvus instance context\n   */\n  install (context) {\n    this.context = context\n    this.isInitialized = true\n\n    this.plugins.forEach(({ plugin, options }) => {\n      this.installPlugin(plugin, options)\n    })\n  }\n\n  /**\n   * Execute a hook\n   *\n   * @param {String} hookName - Name of the hook\n   * @param {*} data - Data to pass to hook callbacks\n   */\n  executeHook (hookName, data) {\n    const callbacks = this.hooks[hookName] || []\n    callbacks.forEach(callback => {\n      try {\n        callback(data)\n      } catch (error) {\n        console.error(`Error in hook \"${hookName}\":`, error)\n      }\n    })\n  }\n\n  /**\n   * Register a hook callback\n   *\n   * @param {String} hookName - Name of the hook\n   * @param {Function} callback - Callback function\n   */\n  addHook (hookName, callback) {\n    if (!this.hooks[hookName]) {\n      this.hooks[hookName] = []\n    }\n    this.hooks[hookName].push(callback)\n  }\n\n  /**\n   * Remove a hook callback\n   *\n   * @param {String} hookName - Name of the hook\n   * @param {Function} callback - Callback function to remove\n   */\n  removeHook (hookName, callback) {\n    if (!this.hooks[hookName]) return\n\n    this.hooks[hookName] = this.hooks[hookName].filter(cb => cb !== callback)\n  }\n\n  /**\n   * Get all registered plugins\n   *\n   * @returns {Array} Array of plugin names\n   */\n  getPlugins () {\n    return this.plugins.map(p => p.plugin.name)\n  }\n}\n"
  },
  {
    "path": "src/js/core/state.js",
    "content": "/**\n * State management for Parvus\n *\n * Centralizes all mutable state variables\n */\nexport class ParvusState {\n  constructor () {\n    // Group management\n    this.GROUP_ATTRIBUTES = {\n      triggerElements: [],\n      slider: null,\n      sliderElements: [],\n      contentElements: []\n    }\n    this.GROUPS = {}\n    this.groupIdCounter = 0\n    this.newGroup = null\n    this.activeGroup = null\n    this.currentIndex = 0\n\n    // Configuration\n    this.config = {}\n\n    // DOM elements\n    this.lightbox = null\n    this.lightboxOverlay = null\n    this.lightboxOverlayOpacity = 1\n    this.toolbar = null\n    this.toolbarLeft = null\n    this.toolbarRight = null\n    this.controls = null\n    this.previousButton = null\n    this.nextButton = null\n    this.closeButton = null\n    this.counter = null\n\n    // Drag & interaction state\n    this.drag = {}\n    this.isDraggingX = false\n    this.isDraggingY = false\n    this.pointerDown = false\n    this.activePointers = new Map()\n\n    // Zoom state\n    this.currentScale = 1\n    this.isPinching = false\n    this.isTap = false\n    this.pinchStartDistance = 0\n    this.lastPointersId = null\n\n    // Offset & animation\n    this.offset = null\n    this.offsetTmp = null\n    this.resizeTicking = false\n    this.isReducedMotion = true\n  }\n\n  /**\n   * Clear drag state\n   */\n  clearDrag () {\n    this.drag = {\n      startX: 0,\n      endX: 0,\n      startY: 0,\n      endY: 0\n    }\n  }\n\n  /**\n   * Get the active group\n   *\n   * @returns {Object} The active group\n   */\n  getActiveGroup () {\n    return this.GROUPS[this.activeGroup]\n  }\n\n  /**\n   * Reset zoom state\n   */\n  resetZoomState () {\n    this.isPinching = false\n    this.isTap = false\n    this.currentScale = 1\n    this.pinchStartDistance = 0\n    this.lastPointersId = ''\n  }\n}\n"
  },
  {
    "path": "src/js/core/utils.js",
    "content": "/**\n * Utils Module\n *\n * Utility functions\n */\n\n/**\n * Check prefers reduced motion\n *\n * @param {Object} state - The application state\n * @param {MediaQueryList} motionQuery - The media query list\n * @returns {void}\n */\nexport const reducedMotionCheck = (state, motionQuery) => {\n  if (motionQuery.matches) {\n    state.isReducedMotion = true\n  } else {\n    state.isReducedMotion = false\n  }\n}\n\n/**\n * Retrieves or creates a group identifier for the given element\n *\n * @param {Object} state - The application state\n * @param {HTMLElement} el - DOM element to get or assign a group to\n * @returns {string} The group identifier associated with the element\n */\nexport const getGroup = (state, el) => {\n  // Return existing group identifier if already assigned\n  if (el.dataset.group) {\n    return el.dataset.group\n  }\n\n  // Generate new unique group identifier using counter\n  const EL_GROUP = `default-${state.groupIdCounter++}`\n\n  // Assign the new group identifier to element's dataset\n  el.dataset.group = EL_GROUP\n\n  return EL_GROUP\n}\n"
  },
  {
    "path": "src/js/handlers/gestures.js",
    "content": "/**\n * Gesture Handler Module\n *\n * Handles gestures like pinch-to-zoom and swipe\n */\n\n/**\n * Reset image zoom\n *\n * @param {Object} state - The application state\n * @param {HTMLImageElement} currentImg - The image\n * @returns {void}\n */\nexport const resetZoom = (state, currentImg) => {\n  currentImg.style.transition = 'transform 0.3s ease'\n  currentImg.style.transform = ''\n\n  setTimeout(() => {\n    currentImg.style.transition = ''\n    currentImg.style.transformOrigin = ''\n  }, 300)\n\n  state.resetZoomState()\n\n  state.lightbox.classList.remove('parvus--is-zooming')\n}\n\n/**\n * Pinch zoom gesture\n *\n * @param {Object} state - The application state\n * @param {HTMLImageElement} currentImg - The image to zoom\n * @returns {void}\n */\nexport const pinchZoom = (state, currentImg) => {\n  // Determine current finger positions\n  const POINTS = Array.from(state.activePointers.values())\n\n  // Calculate current distance between fingers\n  const CURRENT_DISTANCE = Math.hypot(\n    POINTS[1].clientX - POINTS[0].clientX,\n    POINTS[1].clientY - POINTS[0].clientY\n  )\n\n  // Calculate the midpoint between the two points\n  const MIDPOINT_X = (POINTS[0].clientX + POINTS[1].clientX) / 2\n  const MIDPOINT_Y = (POINTS[0].clientY + POINTS[1].clientY) / 2\n\n  // Convert midpoint to relative position within the image\n  const IMG_RECT = currentImg.getBoundingClientRect()\n  const RELATIVE_X = (MIDPOINT_X - IMG_RECT.left) / IMG_RECT.width\n  const RELATIVE_Y = (MIDPOINT_Y - IMG_RECT.top) / IMG_RECT.height\n\n  // When pinch gesture is about to start or the finger IDs have changed\n  // Use a unique ID based on the pointer IDs to recognize changes\n  const CURRENT_POINTERS_ID = POINTS.map(p => p.pointerId).sort().join('-')\n  const IS_NEW_POINTER_COMBINATION = state.lastPointersId !== CURRENT_POINTERS_ID\n\n  if (!state.isPinching || IS_NEW_POINTER_COMBINATION) {\n    state.isPinching = true\n    state.lastPointersId = CURRENT_POINTERS_ID\n\n    // Save the start distance and current scaling as a basis\n    state.pinchStartDistance = CURRENT_DISTANCE / state.currentScale\n\n    // Store initial pinch position for this gesture\n    if ((!currentImg.style.transformOrigin && state.currentScale === 1) ||\n      (state.currentScale === 1 && IS_NEW_POINTER_COMBINATION)) {\n      // Set the transform origin to the pinch midpoint\n      currentImg.style.transformOrigin = `${RELATIVE_X * 100}% ${RELATIVE_Y * 100}%`\n    }\n\n    state.lightbox.classList.add('parvus--is-zooming')\n  }\n\n  // Calculate scaling factor based on distance change\n  const SCALE_FACTOR = CURRENT_DISTANCE / state.pinchStartDistance\n\n  // Limit scaling to 1 - 3\n  state.currentScale = Math.min(Math.max(1, SCALE_FACTOR), 3)\n\n  currentImg.style.willChange = 'transform'\n  currentImg.style.transform = `scale(${state.currentScale})`\n}\n\n/**\n * Determine the swipe direction (horizontal or vertical)\n *\n * @param {Object} state - The application state\n * @returns {void}\n */\nexport const doSwipe = (state) => {\n  const MOVEMENT_THRESHOLD = 1.5\n  const MAX_OPACITY_DISTANCE = 100\n  const DIRECTION_BIAS = 1.15\n\n  const { startX, endX, startY, endY } = state.drag\n  const MOVEMENT_X = startX - endX\n  const MOVEMENT_Y = endY - startY\n  const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X)\n  const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y)\n\n  const GROUP = state.GROUPS[state.activeGroup]\n  const SLIDER = GROUP.slider\n  const TOTAL_SLIDES = GROUP.triggerElements.length\n\n  const handleHorizontalSwipe = (movementX, distance) => {\n    const IS_FIRST_SLIDE = state.currentIndex === 0\n    const IS_LAST_SLIDE = state.currentIndex === TOTAL_SLIDES - 1\n\n    const IS_LEFT_SWIPE = movementX > 0\n    const IS_RIGHT_SWIPE = movementX < 0\n\n    if ((IS_FIRST_SLIDE && IS_RIGHT_SWIPE) || (IS_LAST_SLIDE && IS_LEFT_SWIPE)) {\n      const DAMPING_FACTOR = 1 / (1 + Math.pow(distance / 100, 0.15))\n      const REDUCED_MOVEMENT = movementX * DAMPING_FACTOR\n\n      SLIDER.style.transform = `\n        translate3d(${state.offsetTmp - Math.round(REDUCED_MOVEMENT)}px, 0, 0)\n      `\n    } else {\n      SLIDER.style.transform = `\n        translate3d(${state.offsetTmp - Math.round(movementX)}px, 0, 0)\n      `\n    }\n  }\n\n  const handleVerticalSwipe = (movementY, distance) => {\n    if (!state.isReducedMotion && distance <= 100) {\n      const NEW_OVERLAY_OPACITY = Math.max(0, state.lightboxOverlayOpacity - (distance / MAX_OPACITY_DISTANCE))\n\n      state.lightboxOverlay.style.opacity = NEW_OVERLAY_OPACITY\n    }\n\n    state.lightbox.classList.add('parvus--is-vertical-closing')\n\n    SLIDER.style.transform = `\n      translate3d(${state.offsetTmp}px, ${Math.round(movementY)}px, 0)\n    `\n  }\n\n  if (state.isDraggingX || state.isDraggingY) {\n    if (state.isDraggingX) {\n      handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE)\n    } else if (state.isDraggingY) {\n      handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE)\n    }\n    return\n  }\n\n  // Direction detection based on the relative ratio of movements\n  if (MOVEMENT_X_DISTANCE > MOVEMENT_THRESHOLD || MOVEMENT_Y_DISTANCE > MOVEMENT_THRESHOLD) {\n    // Horizontal swipe if X-movement is stronger than Y-movement * DIRECTION_BIAS\n    if (MOVEMENT_X_DISTANCE > MOVEMENT_Y_DISTANCE * DIRECTION_BIAS && TOTAL_SLIDES > 1) {\n      state.isDraggingX = true\n      state.isDraggingY = false\n\n      handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE)\n    } else if (MOVEMENT_Y_DISTANCE > MOVEMENT_X_DISTANCE * DIRECTION_BIAS && state.config.swipeClose) {\n      // Vertical swipe if Y-movement is stronger than X-movement * DIRECTION_BIAS\n      state.isDraggingX = false\n      state.isDraggingY = true\n\n      handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE)\n    }\n  }\n}\n\n/**\n * Recalculate drag/swipe event after pointerup\n *\n * @param {Object} state - The application state\n * @param {Object} actions - Navigation actions\n * @returns {void}\n */\nexport const updateAfterDrag = (state, actions) => {\n  const { startX, startY, endX, endY } = state.drag\n  const MOVEMENT_X = endX - startX\n  const MOVEMENT_Y = endY - startY\n  const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X)\n  const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y)\n  const { triggerElements } = state.GROUPS[state.activeGroup]\n  const TOTAL_TRIGGER_ELEMENTS = triggerElements.length\n\n  if (state.isDraggingX) {\n    const IS_RIGHT_SWIPE = MOVEMENT_X > 0\n\n    if (MOVEMENT_X_DISTANCE >= state.config.threshold) {\n      if (IS_RIGHT_SWIPE && state.currentIndex > 0) {\n        actions.previous()\n      } else if (!IS_RIGHT_SWIPE && state.currentIndex < TOTAL_TRIGGER_ELEMENTS - 1) {\n        actions.next()\n      }\n    }\n\n    actions.updateOffset()\n  } else if (state.isDraggingY) {\n    if (MOVEMENT_Y_DISTANCE >= state.config.threshold && state.config.swipeClose) {\n      actions.close()\n    } else {\n      state.lightbox.classList.remove('parvus--is-vertical-closing')\n\n      actions.updateOffset()\n    }\n\n    state.lightboxOverlay.style.opacity = ''\n  } else {\n    actions.updateOffset()\n  }\n}\n"
  },
  {
    "path": "src/js/handlers/images.js",
    "content": "/**\n * Image Handler Module\n *\n * Handles image loading, captions, and dimensions\n */\n\n/**\n * Add caption to the container element\n *\n * @param {Object} config - Configuration object\n * @param {HTMLElement} containerEl - The container element to which the caption will be added\n * @param {HTMLElement} imageEl - The image the caption is linked to\n * @param {HTMLElement} el - The trigger element associated with the caption\n * @param {Number} index - The index of the caption\n * @returns {void}\n */\nexport const addCaption = (config, containerEl, imageEl, el, index) => {\n  const getCaptionData = (triggerEl) => {\n    const { captionsAttribute, captionsSelector, captionsIdAttribute = 'data-caption-id' } = config\n\n    // Check for an ID reference on the trigger element\n    // This allows the caption to be anywhere on the page\n    const CAPTION_ID = triggerEl.getAttribute(captionsIdAttribute)\n\n    if (CAPTION_ID) {\n      const CAPTION_EL = document.getElementById(CAPTION_ID)\n\n      if (CAPTION_EL) {\n        return CAPTION_EL.innerHTML\n      }\n    }\n\n    // Check for a direct caption attribute on the trigger element\n    const DIRECT_CAPTION = triggerEl.getAttribute(captionsAttribute)\n\n    if (DIRECT_CAPTION) {\n      return DIRECT_CAPTION\n    }\n\n    // Query for a selector inside the trigger element\n    if (captionsSelector !== 'self') {\n      const CAPTION_EL = triggerEl.querySelector(captionsSelector)\n\n      if (CAPTION_EL) {\n        // Prefer a direct attribute on the found element, otherwise use its content\n        return CAPTION_EL.getAttribute(captionsAttribute) || CAPTION_EL.innerHTML\n      }\n    }\n\n    return null\n  }\n\n  const CAPTION_DATA = getCaptionData(el)\n\n  if (CAPTION_DATA) {\n    const CAPTION_CONTAINER = document.createElement('div')\n    const CAPTION_ID = `parvus__caption-${index}`\n\n    CAPTION_CONTAINER.className = 'parvus__caption'\n    CAPTION_CONTAINER.id = CAPTION_ID\n    CAPTION_CONTAINER.innerHTML = `<p>${CAPTION_DATA}</p>`\n\n    containerEl.appendChild(CAPTION_CONTAINER)\n    imageEl.setAttribute('aria-describedby', CAPTION_ID)\n  }\n}\n\n/**\n * Add copyright to the image container element\n *\n * @param {Object} config - Configuration object\n * @param {HTMLElement} imageContainer - The image container element (parvus__content) to which the copyright will be added\n * @param {HTMLElement} imageEl - The image the copyright is linked to\n * @param {HTMLElement} el - The trigger element associated with the copyright\n * @param {Number} index - The index of the copyright\n * @returns {void}\n */\nexport const addCopyright = (config, imageContainer, imageEl, el, index) => {\n  const getCopyrightData = (triggerEl) => {\n    const { copyrightAttribute, copyrightSelector, copyrightIdAttribute = 'data-copyright-id' } = config\n\n    // Check for an ID reference on the trigger element\n    // This allows the copyright to be anywhere on the page\n    const COPYRIGHT_ID = triggerEl.getAttribute(copyrightIdAttribute)\n\n    if (COPYRIGHT_ID) {\n      const COPYRIGHT_EL = document.getElementById(COPYRIGHT_ID)\n\n      if (COPYRIGHT_EL) {\n        return COPYRIGHT_EL.innerHTML\n      }\n    }\n\n    // Check for a direct copyright attribute on the trigger element\n    const DIRECT_COPYRIGHT = triggerEl.getAttribute(copyrightAttribute)\n\n    if (DIRECT_COPYRIGHT) {\n      return DIRECT_COPYRIGHT\n    }\n\n    // Query for a selector inside the trigger element\n    if (copyrightSelector !== 'self') {\n      const COPYRIGHT_EL = triggerEl.querySelector(copyrightSelector)\n\n      if (COPYRIGHT_EL) {\n        // Prefer a direct attribute on the found element, otherwise use its content\n        return COPYRIGHT_EL.getAttribute(copyrightAttribute) || COPYRIGHT_EL.innerHTML\n      }\n    }\n\n    return null\n  }\n\n  const COPYRIGHT_DATA = getCopyrightData(el)\n\n  if (COPYRIGHT_DATA) {\n    const COPYRIGHT_CONTAINER = document.createElement('div')\n    const COPYRIGHT_ID = `parvus__copyright-${index}`\n\n    COPYRIGHT_CONTAINER.className = 'parvus__copyright'\n    COPYRIGHT_CONTAINER.id = COPYRIGHT_ID\n    COPYRIGHT_CONTAINER.innerHTML = `<small>${COPYRIGHT_DATA}</small>`\n\n    imageContainer.appendChild(COPYRIGHT_CONTAINER)\n\n    // If image already has aria-describedby (from caption), append copyright ID\n    const existingAriaDescribedby = imageEl.getAttribute('aria-describedby')\n    if (existingAriaDescribedby) {\n      imageEl.setAttribute('aria-describedby', `${existingAriaDescribedby} ${COPYRIGHT_ID}`)\n    } else {\n      imageEl.setAttribute('aria-describedby', COPYRIGHT_ID)\n    }\n  }\n}\n\n/**\n * Create image\n *\n * @param {Object} state - The application state\n * @param {HTMLElement} el - The trigger element\n * @param {Number} index - The index\n * @param {Function} callback - Callback function\n * @returns {void}\n */\nexport const createImage = (state, el, index, callback) => {\n  const { contentElements, sliderElements } = state.GROUPS[state.activeGroup]\n\n  if (contentElements[index] !== undefined) {\n    if (callback && typeof callback === 'function') {\n      callback()\n    }\n    return\n  }\n\n  const CONTENT_CONTAINER_EL = sliderElements[index].querySelector('div')\n  const IMAGE = new Image()\n  const IMAGE_CONTAINER = document.createElement('div')\n  const THUMBNAIL = el.querySelector('img')\n  const LOADING_INDICATOR = document.createElement('div')\n\n  IMAGE_CONTAINER.className = 'parvus__content'\n\n  // Create loading indicator\n  LOADING_INDICATOR.className = 'parvus__loader'\n  LOADING_INDICATOR.setAttribute('role', 'progressbar')\n  LOADING_INDICATOR.setAttribute('aria-label', state.config.l10n.lightboxLoadingIndicatorLabel)\n\n  // Add loading indicator to content container\n  CONTENT_CONTAINER_EL.appendChild(LOADING_INDICATOR)\n\n  const checkImagePromise = new Promise((resolve, reject) => {\n    IMAGE.onload = () => resolve(IMAGE)\n    IMAGE.onerror = (error) => reject(error)\n  })\n\n  checkImagePromise\n    .then((loadedImage) => {\n      loadedImage.style.opacity = 0\n\n      IMAGE_CONTAINER.appendChild(loadedImage)\n\n      // Add copyright if available (inside IMAGE_CONTAINER)\n      if (state.config.copyright) {\n        addCopyright(state.config, IMAGE_CONTAINER, IMAGE, el, index)\n      }\n\n      CONTENT_CONTAINER_EL.appendChild(IMAGE_CONTAINER)\n\n      // Add caption if available\n      if (state.config.captions) {\n        addCaption(state.config, CONTENT_CONTAINER_EL, IMAGE, el, index)\n      }\n\n      contentElements[index] = loadedImage\n\n      // Set image width and height\n      loadedImage.setAttribute('width', loadedImage.naturalWidth)\n      loadedImage.setAttribute('height', loadedImage.naturalHeight)\n\n      // Set image dimension\n      setImageDimension(sliderElements[index], loadedImage)\n    })\n    .catch(() => {\n      const ERROR_CONTAINER = document.createElement('div')\n\n      ERROR_CONTAINER.classList.add('parvus__content')\n      ERROR_CONTAINER.classList.add('parvus__content--error')\n\n      ERROR_CONTAINER.textContent = state.config.l10n.lightboxLoadingError\n\n      CONTENT_CONTAINER_EL.appendChild(ERROR_CONTAINER)\n\n      contentElements[index] = ERROR_CONTAINER\n    })\n    .finally(() => {\n      CONTENT_CONTAINER_EL.removeChild(LOADING_INDICATOR)\n\n      if (callback && typeof callback === 'function') {\n        callback()\n      }\n    })\n\n  // Add `sizes` attribute\n  if (el.hasAttribute('data-sizes') && el.getAttribute('data-sizes') !== '') {\n    IMAGE.setAttribute('sizes', el.getAttribute('data-sizes'))\n  }\n\n  // Add `srcset` attribute\n  if (el.hasAttribute('data-srcset') && el.getAttribute('data-srcset') !== '') {\n    IMAGE.setAttribute('srcset', el.getAttribute('data-srcset'))\n  }\n\n  // Add `src` attribute\n  if (el.tagName === 'A') {\n    IMAGE.setAttribute('src', el.href)\n  } else {\n    IMAGE.setAttribute('src', el.getAttribute('data-target'))\n  }\n\n  // `alt` attribute\n  if (THUMBNAIL && THUMBNAIL.hasAttribute('alt') && THUMBNAIL.getAttribute('alt') !== '') {\n    IMAGE.alt = THUMBNAIL.alt\n  } else if (el.hasAttribute('data-alt') && el.getAttribute('data-alt') !== '') {\n    IMAGE.alt = el.getAttribute('data-alt')\n  } else {\n    IMAGE.alt = ''\n  }\n}\n\n/**\n * Load Image\n *\n * @param {Object} state - The application state\n * @param {Number} index - The index of the image to load\n * @param {Boolean} animate - Whether to animate the image\n * @returns {void}\n */\nexport const loadImage = (state, index, animate) => {\n  const IMAGE = state.GROUPS[state.activeGroup].contentElements[index]\n\n  if (IMAGE && IMAGE.tagName === 'IMG') {\n    const THUMBNAIL = state.GROUPS[state.activeGroup].triggerElements[index]\n\n    if (animate && document.startViewTransition) {\n      THUMBNAIL.style.viewTransitionName = 'lightboximage'\n\n      const transition = document.startViewTransition(() => {\n        IMAGE.style.opacity = ''\n        THUMBNAIL.style.viewTransitionName = null\n\n        IMAGE.style.viewTransitionName = 'lightboximage'\n      })\n\n      transition.finished.finally(() => {\n        IMAGE.style.viewTransitionName = null\n      })\n    } else {\n      IMAGE.style.opacity = ''\n    }\n  } else {\n    IMAGE.style.opacity = ''\n  }\n}\n\n/**\n * Set image dimension\n *\n * @param {HTMLElement} slideEl - The slide element\n * @param {HTMLElement} contentEl - The content element\n * @returns {void}\n */\nexport const setImageDimension = (slideEl, contentEl) => {\n  if (contentEl.tagName !== 'IMG') {\n    return\n  }\n\n  const SRC_HEIGHT = contentEl.getAttribute('height')\n  const SRC_WIDTH = contentEl.getAttribute('width')\n\n  if (!SRC_HEIGHT || !SRC_WIDTH) {\n    return\n  }\n\n  const SLIDE_EL_STYLES = getComputedStyle(slideEl)\n\n  const HORIZONTAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingLeft) + parseFloat(SLIDE_EL_STYLES.paddingRight)\n  const VERTICAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingTop) + parseFloat(SLIDE_EL_STYLES.paddingBottom)\n\n  const CAPTION_EL = slideEl.querySelector('.parvus__caption')\n  const CAPTION_HEIGHT = CAPTION_EL ? CAPTION_EL.getBoundingClientRect().height : 0\n\n  const MAX_WIDTH = slideEl.offsetWidth - HORIZONTAL_PADDING\n  const MAX_HEIGHT = slideEl.offsetHeight - VERTICAL_PADDING - CAPTION_HEIGHT\n\n  const RATIO = Math.min(MAX_WIDTH / SRC_WIDTH || 0, MAX_HEIGHT / SRC_HEIGHT || 0)\n\n  const NEW_WIDTH = SRC_WIDTH * RATIO\n  const NEW_HEIGHT = SRC_HEIGHT * RATIO\n\n  const USE_ORIGINAL_SIZE = (SRC_WIDTH <= MAX_WIDTH && SRC_HEIGHT <= MAX_HEIGHT)\n\n  contentEl.style.width = USE_ORIGINAL_SIZE ? '' : `${NEW_WIDTH}px`\n  contentEl.style.height = USE_ORIGINAL_SIZE ? '' : `${NEW_HEIGHT}px`\n}\n\n/**\n * Create resize handler\n *\n * @param {Object} state - The application state\n * @param {Function} updateOffset - Update offset function\n * @returns {Function} Resize event handler\n */\nexport const createResizeHandler = (state, updateOffset) => {\n  return () => {\n    if (!state.resizeTicking) {\n      state.resizeTicking = true\n\n      window.requestAnimationFrame(() => {\n        state.GROUPS[state.activeGroup].sliderElements.forEach((slide, index) => {\n          setImageDimension(slide, state.GROUPS[state.activeGroup].contentElements[index])\n        })\n\n        updateOffset()\n\n        state.resizeTicking = false\n      })\n    }\n  }\n}\n"
  },
  {
    "path": "src/js/handlers/keyboard.js",
    "content": "/**\n * Keyboard Event Handler Module\n *\n * Handles all keyboard interactions\n */\n\nimport { getFocusableChildren } from '../helpers/dom.js'\n\n/**\n * Create keyboard event handler\n *\n * @param {Object} state - The application state\n * @param {Object} actions - Actions object with navigation functions\n * @returns {Function} Keyboard event handler\n */\nexport const createKeydownHandler = (state, actions) => {\n  return (event) => {\n    const FOCUSABLE_CHILDREN = getFocusableChildren(state.lightbox)\n    const FOCUSED_ITEM_INDEX = FOCUSABLE_CHILDREN.indexOf(document.activeElement)\n    const lastIndex = FOCUSABLE_CHILDREN.length - 1\n\n    switch (event.code) {\n      case 'Tab': {\n        // Use the TAB key to navigate backwards and forwards\n        if (event.shiftKey) {\n          // Navigate backwards\n          if (FOCUSED_ITEM_INDEX === 0) {\n            FOCUSABLE_CHILDREN[lastIndex].focus()\n            event.preventDefault()\n          }\n        } else {\n          // Navigate forwards\n          if (FOCUSED_ITEM_INDEX === lastIndex) {\n            FOCUSABLE_CHILDREN[0].focus()\n            event.preventDefault()\n          }\n        }\n        break\n      }\n      case 'Escape': {\n        // Close Parvus when the ESC key is pressed\n        actions.close()\n        event.preventDefault()\n        break\n      }\n      case 'ArrowLeft': {\n        // Show the previous slide when the PREV key is pressed\n        actions.previous()\n        event.preventDefault()\n        break\n      }\n      case 'ArrowRight': {\n        // Show the next slide when the NEXT key is pressed\n        actions.next()\n        event.preventDefault()\n        break\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/js/handlers/pointer.js",
    "content": "/**\n * Pointer Event Handler Module\n *\n * Handles all pointer interactions (mouse, touch, pen)\n */\n\n/**\n * Create pointerdown event handler\n *\n * @param {Object} state - The application state\n * @returns {Function} Pointerdown event handler\n */\nexport const createPointerdownHandler = (state) => {\n  return (event) => {\n    event.preventDefault()\n    event.stopPropagation()\n\n    state.isDraggingX = false\n    state.isDraggingY = false\n\n    state.pointerDown = true\n\n    state.activePointers.set(event.pointerId, event)\n\n    state.drag.startX = event.pageX\n    state.drag.startY = event.pageY\n    state.drag.endX = event.pageX\n    state.drag.endY = event.pageY\n\n    const { slider } = state.GROUPS[state.activeGroup]\n\n    slider.classList.add('parvus__slider--is-dragging')\n    slider.style.willChange = 'transform'\n\n    state.isTap = state.activePointers.size === 1\n\n    if (state.config.swipeClose) {\n      state.lightboxOverlayOpacity = getComputedStyle(state.lightboxOverlay).opacity\n    }\n  }\n}\n\n/**\n * Create pointermove event handler\n *\n * @param {Object} state - The application state\n * @param {Function} pinchZoom - Pinch zoom function\n * @param {Function} doSwipe - Swipe function\n * @returns {Function} Pointermove event handler\n */\nexport const createPointermoveHandler = (state, pinchZoom, doSwipe) => {\n  return (event) => {\n    event.preventDefault()\n\n    if (!state.pointerDown) {\n      return\n    }\n\n    const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex]\n\n    // Update pointer position\n    state.activePointers.set(event.pointerId, event)\n\n    // Zoom\n    if (CURRENT_IMAGE && CURRENT_IMAGE.tagName === 'IMG') {\n      if (state.activePointers.size === 2) {\n        pinchZoom(CURRENT_IMAGE)\n\n        return\n      }\n\n      if (state.currentScale > 1) {\n        return\n      }\n    }\n\n    state.drag.endX = event.pageX\n    state.drag.endY = event.pageY\n\n    doSwipe()\n  }\n}\n\n/**\n * Create pointerup event handler\n *\n * @param {Object} state - The application state\n * @param {Function} resetZoom - Reset zoom function\n * @param {Function} updateAfterDrag - Update after drag function\n * @returns {Function} Pointerup event handler\n */\nexport const createPointerupHandler = (state, resetZoom, updateAfterDrag) => {\n  return (event) => {\n    event.stopPropagation()\n\n    const { slider } = state.GROUPS[state.activeGroup]\n\n    state.activePointers.delete(event.pointerId)\n\n    if (state.activePointers.size > 0) {\n      return\n    }\n\n    state.pointerDown = false\n\n    const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex]\n\n    // Reset zoom state by one tap\n    const MOVEMENT_X = Math.abs(state.drag.endX - state.drag.startX)\n    const MOVEMENT_Y = Math.abs(state.drag.endY - state.drag.startY)\n\n    const IS_TAP = MOVEMENT_X < 8 && MOVEMENT_Y < 8 && !state.isDraggingX && !state.isDraggingY && state.isTap\n\n    slider.classList.remove('parvus__slider--is-dragging')\n    slider.style.willChange = ''\n\n    if (state.currentScale > 1) {\n      if (IS_TAP) {\n        resetZoom(CURRENT_IMAGE)\n      } else {\n        CURRENT_IMAGE.style.transform = `\n          scale(${state.currentScale})\n        `\n      }\n    } else {\n      if (state.isPinching) {\n        resetZoom(CURRENT_IMAGE)\n      }\n\n      if (state.drag.endX || state.drag.endY) {\n        updateAfterDrag()\n      }\n    }\n\n    state.clearDrag()\n  }\n}\n\n/**\n * Create click event handler\n *\n * @param {Object} state - The application state\n * @param {Object} actions - Actions object with navigation functions\n * @returns {Function} Click event handler\n */\nexport const createClickHandler = (state, actions) => {\n  return (event) => {\n    const { target } = event\n\n    if (target === state.previousButton) {\n      actions.previous()\n    } else if (target === state.nextButton) {\n      actions.next()\n    } else if (target === state.closeButton || (state.config.docClose && !state.isDraggingY && !state.isDraggingX && target.classList.contains('parvus__slide'))) {\n      actions.close()\n    }\n\n    event.stopPropagation()\n  }\n}\n"
  },
  {
    "path": "src/js/helpers/dom.js",
    "content": "const BROWSER_WINDOW = window\n\n/**\n * Get scrollbar width\n *\n * @return {Number} - The scrollbar width\n */\nexport const getScrollbarWidth = () => {\n  return BROWSER_WINDOW.innerWidth - document.documentElement.clientWidth\n}\n\nconst FOCUSABLE_ELEMENTS = [\n  'a:not([inert]):not([tabindex^=\"-\"])',\n  'button:not([inert]):not([tabindex^=\"-\"]):not(:disabled)',\n  '[tabindex]:not([inert]):not([tabindex^=\"-\"])'\n]\n\n/**\n * Get the focusable children of the given element\n *\n * @return {Array<Element>} - An array of focusable children\n */\nexport const getFocusableChildren = (targetEl) => {\n  return Array.from(targetEl.querySelectorAll(FOCUSABLE_ELEMENTS.join(', ')))\n    .filter((child) => child.offsetParent !== null)\n}\n"
  },
  {
    "path": "src/js/parvus.js",
    "content": "// Helper modules\nimport { getScrollbarWidth } from './helpers/dom.js'\n\n// Core modules\nimport { mergeOptions } from './core/config.js'\nimport { ParvusState } from './core/state.js'\nimport { dispatchCustomEvent, on as addEventListener, off as removeEventListener } from './core/events.js'\nimport { updateOffset, loadSlide, leaveSlide, preload } from './core/navigation.js'\nimport { reducedMotionCheck, getGroup } from './core/utils.js'\nimport { PluginManager } from './core/plugins.js'\n\n// UI modules\nimport { createLightbox, createSlider, createSlide, updateCounter, updateAttributes, updateSliderNavigationStatus } from './ui/lightbox.js'\nimport { addZoomIndicator, removeZoomIndicator } from './ui/zoom-indicator.js'\n\n// Handler modules\nimport { createKeydownHandler } from './handlers/keyboard.js'\nimport { createPointerdownHandler, createPointermoveHandler, createPointerupHandler, createClickHandler } from './handlers/pointer.js'\nimport { resetZoom, pinchZoom, doSwipe, updateAfterDrag } from './handlers/gestures.js'\nimport { createImage, loadImage, createResizeHandler } from './handlers/images.js'\n\n/**\n * Parvus Lightbox\n *\n * @param {Object} userOptions - User configuration options\n * @returns {Object} Parvus instance\n */\nexport default function Parvus (userOptions) {\n  const BROWSER_WINDOW = window\n  const STATE = new ParvusState()\n  const MOTIONQUERY = BROWSER_WINDOW.matchMedia('(prefers-reduced-motion)')\n  const PLUGIN_MANAGER = new PluginManager()\n\n  // Event handlers will be created after actions are defined\n  let keydownHandler, clickHandler, pointerdownHandler, pointermoveHandler, pointerupHandler, resizeHandler\n\n  /**\n   * Click event handler to trigger Parvus\n   *\n   * @param {Event} event - The click event object\n   */\n  const triggerParvus = function triggerParvus (event) {\n    event.preventDefault()\n\n    open(this)\n  }\n\n  /**\n   * Add an element\n   *\n   * @param {HTMLElement} el - The element to be added\n   */\n  const add = (el) => {\n    // Check element type and attributes\n    const IS_VALID_LINK = el.tagName === 'A' && el.hasAttribute('href')\n    const IS_VALID_BUTTON = el.tagName === 'BUTTON' && el.hasAttribute('data-target')\n\n    if (!IS_VALID_LINK && !IS_VALID_BUTTON) {\n      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.')\n    }\n\n    // Check if the lightbox already exists\n    if (!STATE.lightbox) {\n      createLightbox(STATE)\n\n      // Execute afterInit hook when lightbox is first created\n      PLUGIN_MANAGER.executeHook('afterInit', { state: STATE })\n    }\n\n    STATE.newGroup = getGroup(STATE, el)\n\n    if (!STATE.GROUPS[STATE.newGroup]) {\n      STATE.GROUPS[STATE.newGroup] = structuredClone(STATE.GROUP_ATTRIBUTES)\n    }\n\n    if (STATE.GROUPS[STATE.newGroup].triggerElements.includes(el)) {\n      throw new Error('Ups, element already added.')\n    }\n\n    STATE.GROUPS[STATE.newGroup].triggerElements.push(el)\n\n    if (STATE.config.zoomIndicator) {\n      addZoomIndicator(el, STATE.config)\n    }\n\n    el.classList.add('parvus-trigger')\n    el.addEventListener('click', triggerParvus)\n\n    if (isOpen() && STATE.newGroup === STATE.activeGroup) {\n      const EL_INDEX = STATE.GROUPS[STATE.newGroup].triggerElements.indexOf(el)\n\n      createSlide(STATE, EL_INDEX)\n      createImage(STATE, el, EL_INDEX, () => {\n        loadImage(STATE, EL_INDEX)\n      })\n      updateAttributes(STATE)\n      updateSliderNavigationStatus(STATE)\n      updateCounter(STATE)\n    }\n  }\n\n  /**\n   * Remove an element\n   *\n   * @param {HTMLElement} el - The element to be removed\n   */\n  const remove = (el) => {\n    if (!el || !el.hasAttribute('data-group')) {\n      return\n    }\n\n    const EL_GROUP = getGroup(STATE, el)\n    const GROUP = STATE.GROUPS[EL_GROUP]\n\n    // Check if element exists\n    if (!GROUP) {\n      return\n    }\n\n    const EL_INDEX = GROUP.triggerElements.indexOf(el)\n\n    if (EL_INDEX === -1) {\n      return\n    }\n\n    const IS_CURRENT_EL = isOpen() && EL_GROUP === STATE.activeGroup && EL_INDEX === STATE.currentIndex\n\n    // Remove group data\n    if (GROUP.contentElements[EL_INDEX]) {\n      const content = GROUP.contentElements[EL_INDEX]\n\n      if (content.tagName === 'IMG') {\n        content.src = ''\n        content.srcset = ''\n      }\n    }\n\n    // Remove DOM element\n    const sliderElement = GROUP.sliderElements[EL_INDEX]\n\n    if (sliderElement && sliderElement.parentNode) {\n      sliderElement.parentNode.removeChild(sliderElement)\n    }\n\n    // Remove all array elements\n    GROUP.triggerElements.splice(EL_INDEX, 1)\n    GROUP.sliderElements.splice(EL_INDEX, 1)\n    GROUP.contentElements.splice(EL_INDEX, 1)\n\n    if (STATE.config.zoomIndicator) {\n      removeZoomIndicator(el)\n    }\n\n    if (isOpen() && EL_GROUP === STATE.activeGroup) {\n      if (IS_CURRENT_EL) {\n        if (GROUP.triggerElements.length === 0) {\n          close()\n        } else if (STATE.currentIndex >= GROUP.triggerElements.length) {\n          select(GROUP.triggerElements.length - 1)\n        } else {\n          updateAttributes(STATE)\n          updateSliderNavigationStatus(STATE)\n          updateCounter(STATE)\n        }\n      } else if (EL_INDEX < STATE.currentIndex) {\n        STATE.currentIndex--\n        updateAttributes(STATE)\n        updateSliderNavigationStatus(STATE)\n        updateCounter(STATE)\n      } else {\n        updateAttributes(STATE)\n        updateSliderNavigationStatus(STATE)\n        updateCounter(STATE)\n      }\n    }\n\n    // Unbind click event handler\n    el.removeEventListener('click', triggerParvus)\n\n    el.classList.remove('parvus-trigger')\n  }\n\n  /**\n   * Open Parvus\n   *\n   * @param {HTMLElement} el\n   */\n  const open = (el) => {\n    if (!STATE.lightbox || !el || !el.classList.contains('parvus-trigger') || isOpen()) {\n      return\n    }\n\n    STATE.activeGroup = getGroup(STATE, el)\n\n    const GROUP = STATE.GROUPS[STATE.activeGroup]\n    const EL_INDEX = GROUP.triggerElements.indexOf(el)\n\n    if (EL_INDEX === -1) {\n      throw new Error('Ups, element not found in group.')\n    }\n\n    STATE.currentIndex = EL_INDEX\n\n    history.pushState({ parvus: 'close' }, 'Image', window.location.href)\n\n    bindEvents()\n\n    if (STATE.config.hideScrollbar) {\n      document.body.style.marginInlineEnd = `${getScrollbarWidth()}px`\n      document.body.style.overflow = 'hidden'\n    }\n\n    STATE.lightbox.classList.add('parvus--is-opening')\n    STATE.lightbox.showModal()\n\n    createSlider(STATE)\n    createSlide(STATE, STATE.currentIndex)\n\n    updateOffset(STATE)\n    updateAttributes(STATE)\n    updateSliderNavigationStatus(STATE)\n    updateCounter(STATE)\n\n    loadSlide(STATE, STATE.currentIndex)\n\n    createImage(STATE, el, STATE.currentIndex, () => {\n      loadImage(STATE, STATE.currentIndex, true)\n      STATE.lightbox.classList.remove('parvus--is-opening')\n\n      GROUP.slider.classList.add('parvus__slider--animate')\n    })\n\n    preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex + 1)\n    preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex - 1)\n\n    // Execute afterOpen hook\n    PLUGIN_MANAGER.executeHook('afterOpen', { element: el, state: STATE })\n\n    // Create and dispatch a new event\n    dispatchCustomEvent(STATE.lightbox, 'open')\n  }\n\n  /**\n   * Close Parvus\n   */\n  const close = () => {\n    if (!isOpen()) {\n      return\n    }\n\n    const IMAGE = STATE.GROUPS[STATE.activeGroup].contentElements[STATE.currentIndex]\n    const THUMBNAIL = STATE.GROUPS[STATE.activeGroup].triggerElements[STATE.currentIndex]\n\n    unbindEvents()\n    STATE.clearDrag()\n\n    if (history.state?.parvus === 'close') {\n      history.back()\n    }\n\n    STATE.lightbox.classList.add('parvus--is-closing')\n\n    const transitionendHandler = () => {\n      // Reset the image zoom (if ESC was pressed or went back in the browser history)\n      // after the ViewTransition (otherwise it looks bad)\n      if (STATE.isPinching) {\n        resetZoom(STATE, IMAGE)\n      }\n\n      leaveSlide(STATE, STATE.currentIndex)\n\n      STATE.lightbox.close()\n      STATE.lightbox.classList.remove('parvus--is-closing')\n      STATE.lightbox.classList.remove('parvus--is-vertical-closing')\n\n      STATE.GROUPS[STATE.activeGroup].slider.remove()\n      STATE.GROUPS[STATE.activeGroup].slider = null\n      STATE.GROUPS[STATE.activeGroup].sliderElements = []\n      STATE.GROUPS[STATE.activeGroup].contentElements = []\n\n      STATE.counter.removeAttribute('aria-hidden')\n\n      STATE.previousButton.removeAttribute('aria-hidden')\n      STATE.previousButton.removeAttribute('aria-disabled')\n\n      STATE.nextButton.removeAttribute('aria-hidden')\n\n      STATE.nextButton.removeAttribute('aria-disabled')\n\n      if (STATE.config.hideScrollbar) {\n        document.body.style.marginInlineEnd = ''\n        document.body.style.overflow = ''\n      }\n\n      // Execute afterClose hook\n      PLUGIN_MANAGER.executeHook('afterClose', { state: STATE })\n    }\n\n    if (IMAGE && IMAGE.tagName === 'IMG') {\n      if (document.startViewTransition) {\n        IMAGE.style.viewTransitionName = 'lightboximage'\n\n        const transition = document.startViewTransition(() => {\n          IMAGE.style.opacity = '0'\n          IMAGE.style.viewTransitionName = null\n\n          THUMBNAIL.style.viewTransitionName = 'lightboximage'\n        })\n\n        transition.finished.finally(() => {\n          transitionendHandler()\n\n          THUMBNAIL.style.viewTransitionName = null\n        })\n      } else {\n        IMAGE.style.opacity = '0'\n\n        requestAnimationFrame(transitionendHandler)\n      }\n    } else {\n      transitionendHandler()\n    }\n  }\n\n  /**\n   * Select a specific slide by index\n   *\n   * @param {number} index - Index of the slide to select\n   */\n  const select = (index) => {\n    if (!isOpen()) {\n      throw new Error(\"Oops, I'm closed.\")\n    }\n\n    if (typeof index !== 'number' || isNaN(index)) {\n      throw new Error('Oops, no slide specified.')\n    }\n\n    const GROUP = STATE.GROUPS[STATE.activeGroup]\n    const triggerElements = GROUP.triggerElements\n\n    if (index === STATE.currentIndex) {\n      throw new Error(`Oops, slide ${index} is already selected.`)\n    }\n\n    if (index < 0 || index >= triggerElements.length) {\n      throw new Error(`Oops, I can't find slide ${index}.`)\n    }\n\n    const OLD_INDEX = STATE.currentIndex\n\n    STATE.currentIndex = index\n\n    if (GROUP.sliderElements[index]) {\n      loadSlide(STATE, index)\n    } else {\n      createSlide(STATE, index)\n      createImage(STATE, GROUP.triggerElements[index], index, () => {\n        loadImage(STATE, index)\n      })\n      loadSlide(STATE, index)\n    }\n\n    updateOffset(STATE)\n    updateSliderNavigationStatus(STATE)\n    updateCounter(STATE)\n\n    // Execute slideChange hook\n    PLUGIN_MANAGER.executeHook('slideChange', { index, oldIndex: OLD_INDEX, state: STATE })\n\n    if (index < OLD_INDEX) {\n      preload(STATE, createSlide, createImage, loadImage, index - 1)\n    } else {\n      preload(STATE, createSlide, createImage, loadImage, index + 1)\n    }\n\n    leaveSlide(STATE, OLD_INDEX)\n\n    // Create and dispatch a new event\n    dispatchCustomEvent(STATE.lightbox, 'select')\n  }\n\n  /**\n   * Select the previous slide\n   */\n  const previous = () => {\n    if (STATE.currentIndex > 0) {\n      select(STATE.currentIndex - 1)\n    }\n  }\n\n  /**\n   * Select the next slide\n   */\n  const next = () => {\n    const { triggerElements } = STATE.GROUPS[STATE.activeGroup]\n\n    if (STATE.currentIndex < triggerElements.length - 1) {\n      select(STATE.currentIndex + 1)\n    }\n  }\n\n  /**\n   * Bind specified events\n   */\n  const bindEvents = () => {\n    const actions = {\n      close,\n      previous,\n      next,\n      updateOffset: () => updateOffset(STATE)\n    }\n\n    // Create handlers with state and actions\n    keydownHandler = createKeydownHandler(STATE, actions)\n    clickHandler = createClickHandler(STATE, actions)\n    resizeHandler = createResizeHandler(STATE, () => updateOffset(STATE))\n\n    const updateAfterDragHandler = () => updateAfterDrag(STATE, actions)\n    const pinchZoomHandler = (img) => pinchZoom(STATE, img)\n    const doSwipeHandler = () => doSwipe(STATE)\n    const resetZoomHandler = (img) => resetZoom(STATE, img)\n\n    pointerdownHandler = createPointerdownHandler(STATE)\n    pointermoveHandler = createPointermoveHandler(STATE, pinchZoomHandler, doSwipeHandler)\n    pointerupHandler = createPointerupHandler(STATE, resetZoomHandler, updateAfterDragHandler)\n\n    BROWSER_WINDOW.addEventListener('keydown', keydownHandler)\n    BROWSER_WINDOW.addEventListener('resize', resizeHandler)\n\n    // Popstate event\n    BROWSER_WINDOW.addEventListener('popstate', close)\n\n    // Check for any OS level changes to the prefers reduced motion preference\n    MOTIONQUERY.addEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY))\n\n    // Click event\n    STATE.lightbox.addEventListener('click', clickHandler)\n\n    // Pointer events\n    STATE.lightbox.addEventListener('pointerdown', pointerdownHandler, { passive: false })\n    STATE.lightbox.addEventListener('pointerup', pointerupHandler, { passive: true })\n    STATE.lightbox.addEventListener('pointermove', pointermoveHandler, { passive: false })\n  }\n\n  /**\n   * Unbind specified events\n   */\n  const unbindEvents = () => {\n    BROWSER_WINDOW.removeEventListener('keydown', keydownHandler)\n    BROWSER_WINDOW.removeEventListener('resize', resizeHandler)\n\n    // Popstate event\n    BROWSER_WINDOW.removeEventListener('popstate', close)\n\n    // Check for any OS level changes to the prefers reduced motion preference\n    MOTIONQUERY.removeEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY))\n\n    // Click event\n    STATE.lightbox.removeEventListener('click', clickHandler)\n\n    // Pointer events\n    STATE.lightbox.removeEventListener('pointerdown', pointerdownHandler)\n    STATE.lightbox.removeEventListener('pointerup', pointerupHandler)\n    STATE.lightbox.removeEventListener('pointermove', pointermoveHandler)\n  }\n\n  /**\n   * Destroy Parvus\n   */\n  const destroy = () => {\n    if (!STATE.lightbox) {\n      return\n    }\n\n    if (isOpen()) {\n      close()\n    }\n\n    // Add setTimeout to ensure all possible close transitions are completed\n    setTimeout(() => {\n      unbindEvents()\n\n      // Remove all registered event listeners for custom events\n      const eventTypes = [\n        'open',\n        'close',\n        'select',\n        'destroy'\n      ]\n\n      eventTypes.forEach(eventType => {\n        const listeners = STATE.lightbox._listeners?.[eventType] || []\n\n        listeners.forEach(listener => {\n          STATE.lightbox.removeEventListener(eventType, listener)\n        })\n      })\n\n      // Remove event listeners from trigger elements\n      const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll('.parvus-trigger')\n\n      LIGHTBOX_TRIGGER_ELS.forEach(el => {\n        el.removeEventListener('click', triggerParvus)\n        el.classList.remove('parvus-trigger')\n\n        if (STATE.config.zoomIndicator) {\n          removeZoomIndicator(el)\n        }\n\n        if (el.dataset.group) {\n          delete el.dataset.group\n        }\n      })\n\n      // Create and dispatch a new event\n      dispatchCustomEvent(STATE.lightbox, 'destroy')\n\n      STATE.lightbox.remove()\n\n      // Remove references\n      STATE.lightbox = null\n      STATE.lightboxOverlay = null\n      STATE.toolbar = null\n      STATE.toolbarLeft = null\n      STATE.toolbarRight = null\n      STATE.controls = null\n      STATE.previousButton = null\n      STATE.nextButton = null\n      STATE.closeButton = null\n      STATE.counter = null\n\n      // Remove group data\n      Object.keys(STATE.GROUPS).forEach(groupKey => {\n        const group = STATE.GROUPS[groupKey]\n\n        if (group && group.contentElements) {\n          group.contentElements.forEach(content => {\n            if (content && content.tagName === 'IMG') {\n              content.src = ''\n              content.srcset = ''\n            }\n          })\n        }\n        delete STATE.GROUPS[groupKey]\n      })\n\n      // Reset variables\n      STATE.groupIdCounter = 0\n      STATE.newGroup = null\n      STATE.activeGroup = null\n      STATE.currentIndex = 0\n    }, 1000)\n  }\n\n  /**\n   * Check if Parvus is open\n   *\n   * @returns {boolean} - True if Parvus is open, otherwise false\n   */\n  const isOpen = () => {\n    return STATE.lightbox?.hasAttribute('open')\n  }\n\n  /**\n   * Get the current index\n   *\n   * @returns {number} - The current index\n   */\n  const getCurrentIndex = () => {\n    return STATE.currentIndex\n  }\n\n  /**\n   * Bind a specific event listener\n   *\n   * @param {String} eventName - The name of the event to bind\n   * @param {Function} callback - The callback function\n   */\n  const on = (eventName, callback) => {\n    addEventListener(STATE.lightbox, eventName, callback)\n  }\n\n  /**\n   * Unbind a specific event listener\n   *\n   * @param {String} eventName - The name of the event to unbind\n   * @param {Function} callback - The callback function\n   */\n  const off = (eventName, callback) => {\n    removeEventListener(STATE.lightbox, eventName, callback)\n  }\n\n  /**\n   * Use a plugin\n   *\n   * @param {Object} plugin - Plugin object\n   * @param {Object} options - Plugin options\n   */\n  const use = (plugin, options = {}) => {\n    PLUGIN_MANAGER.register(plugin, options)\n  }\n\n  /**\n   * Add a hook callback\n   *\n   * @param {String} hookName - Hook name\n   * @param {Function} callback - Callback function\n   */\n  const addHook = (hookName, callback) => {\n    PLUGIN_MANAGER.addHook(hookName, callback)\n  }\n\n  /**\n   * Get registered plugins\n   *\n   * @returns {Array} Array of plugin names\n   */\n  const getPlugins = () => {\n    return PLUGIN_MANAGER.getPlugins()\n  }\n\n  /**\n   * Init\n   */\n  const init = () => {\n    // Merge user options into defaults\n    STATE.config = mergeOptions(userOptions)\n\n    reducedMotionCheck(STATE, MOTIONQUERY)\n\n    // Install plugins with context\n    const pluginContext = {\n      state: STATE,\n      on: addEventListener,\n      addHook: PLUGIN_MANAGER.addHook.bind(PLUGIN_MANAGER),\n      config: STATE.config\n    }\n    PLUGIN_MANAGER.install(pluginContext)\n\n    if (STATE.config.gallerySelector !== null) {\n      // Get a list of all `gallerySelector` elements within the document\n      const GALLERY_ELS = document.querySelectorAll(STATE.config.gallerySelector)\n\n      // Execute a few things once per element\n      GALLERY_ELS.forEach((galleryEl, index) => {\n        const GALLERY_INDEX = index\n        // Get a list of all `selector` elements within the `gallerySelector`\n        const LIGHTBOX_TRIGGER_GALLERY_ELS = galleryEl.querySelectorAll(STATE.config.selector)\n\n        // Execute a few things once per element\n        LIGHTBOX_TRIGGER_GALLERY_ELS.forEach((lightboxTriggerEl) => {\n          lightboxTriggerEl.setAttribute('data-group', `parvus-gallery-${GALLERY_INDEX}`)\n          add(lightboxTriggerEl)\n        })\n      })\n    }\n\n    // Get a list of all `selector` elements outside or without the `gallerySelector`\n    const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(`${STATE.config.selector}:not(.parvus-trigger)`)\n\n    LIGHTBOX_TRIGGER_ELS.forEach(add)\n  }\n\n  init()\n\n  return {\n    init,\n    open,\n    close,\n    select,\n    previous,\n    next,\n    currentIndex: getCurrentIndex,\n    add,\n    remove,\n    destroy,\n    isOpen,\n    on,\n    off,\n    use,\n    addHook,\n    getPlugins\n  }\n}\n"
  },
  {
    "path": "src/js/ui/lightbox.js",
    "content": "/**\n * UI Components Module\n *\n * Handles creation of lightbox, toolbar, slider and slides\n */\n\n/**\n * Create the lightbox\n *\n * @param {Object} state - The application state\n * @returns {void}\n */\nexport const createLightbox = (state) => {\n  const { config } = state\n\n  // Use DocumentFragment to batch DOM operations\n  const fragment = document.createDocumentFragment()\n\n  // Create the lightbox container\n  state.lightbox = document.createElement('dialog')\n  state.lightbox.setAttribute('role', 'dialog')\n  state.lightbox.setAttribute('aria-modal', 'true')\n  state.lightbox.setAttribute('aria-label', config.l10n.lightboxLabel)\n  state.lightbox.classList.add('parvus')\n\n  // Create the lightbox overlay container\n  state.lightboxOverlay = document.createElement('div')\n  state.lightboxOverlay.classList.add('parvus__overlay')\n\n  // Create the toolbar\n  state.toolbar = document.createElement('div')\n  state.toolbar.className = 'parvus__toolbar'\n\n  // Create the toolbar items\n  state.toolbarLeft = document.createElement('div')\n  state.toolbarRight = document.createElement('div')\n\n  // Create the controls\n  state.controls = document.createElement('div')\n  state.controls.className = 'parvus__controls'\n  state.controls.setAttribute('role', 'group')\n  state.controls.setAttribute('aria-label', config.l10n.controlsLabel)\n\n  // Create the close button\n  state.closeButton = document.createElement('button')\n  state.closeButton.className = 'parvus__btn parvus__btn--close'\n  state.closeButton.setAttribute('type', 'button')\n  state.closeButton.setAttribute('aria-label', config.l10n.closeButtonLabel)\n  state.closeButton.innerHTML = config.closeButtonIcon\n\n  // Create the previous button\n  state.previousButton = document.createElement('button')\n  state.previousButton.className = 'parvus__btn parvus__btn--previous'\n  state.previousButton.setAttribute('type', 'button')\n  state.previousButton.setAttribute('aria-label', config.l10n.previousButtonLabel)\n  state.previousButton.innerHTML = config.previousButtonIcon\n\n  // Create the next button\n  state.nextButton = document.createElement('button')\n  state.nextButton.className = 'parvus__btn parvus__btn--next'\n  state.nextButton.setAttribute('type', 'button')\n  state.nextButton.setAttribute('aria-label', config.l10n.nextButtonLabel)\n  state.nextButton.innerHTML = config.nextButtonIcon\n\n  // Create the counter\n  state.counter = document.createElement('div')\n  state.counter.className = 'parvus__counter'\n\n  // Add the control buttons to the controls\n  state.controls.append(state.closeButton, state.previousButton, state.nextButton)\n\n  // Add the counter to the left toolbar item\n  state.toolbarLeft.appendChild(state.counter)\n\n  // Add the controls to the right toolbar item\n  state.toolbarRight.appendChild(state.controls)\n\n  // Add the toolbar items to the toolbar\n  state.toolbar.append(state.toolbarLeft, state.toolbarRight)\n\n  // Add the overlay and the toolbar to the lightbox\n  state.lightbox.append(state.lightboxOverlay, state.toolbar)\n  fragment.appendChild(state.lightbox)\n\n  // Add to document body\n  document.body.appendChild(fragment)\n}\n\n/**\n * Create a slider\n *\n * @param {Object} state - The application state\n * @returns {void}\n */\nexport const createSlider = (state) => {\n  const SLIDER = document.createElement('div')\n\n  SLIDER.className = 'parvus__slider'\n\n  // Update the slider reference in GROUPS\n  state.GROUPS[state.activeGroup].slider = SLIDER\n\n  // Add the slider to the lightbox container\n  state.lightbox.appendChild(SLIDER)\n}\n\n/**\n * Get next slide index\n *\n * @param {Object} state - The application state\n * @param {Number} currentIndex - Current slide index\n * @returns {number} Index of the next available slide or -1 if none found\n */\nexport const getNextSlideIndex = (state, currentIndex) => {\n  const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements\n  const TOTAL_SLIDE_ELEMENTS = SLIDE_ELEMENTS.length\n\n  for (let i = currentIndex + 1; i < TOTAL_SLIDE_ELEMENTS; i++) {\n    if (SLIDE_ELEMENTS[i] !== undefined) {\n      return i\n    }\n  }\n\n  return -1\n}\n\n/**\n * Get previous slide index\n *\n * @param {Object} state - The application state\n * @param {number} currentIndex - Current slide index\n * @returns {number} Index of the previous available slide or -1 if none found\n */\nexport const getPreviousSlideIndex = (state, currentIndex) => {\n  const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements\n\n  for (let i = currentIndex - 1; i >= 0; i--) {\n    if (SLIDE_ELEMENTS[i] !== undefined) {\n      return i\n    }\n  }\n\n  return -1\n}\n\n/**\n * Create a slide\n *\n * @param {Object} state - The application state\n * @param {Number} index - The index of the slide\n * @returns {void}\n */\nexport const createSlide = (state, index) => {\n  if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {\n    return\n  }\n\n  const FRAGMENT = document.createDocumentFragment()\n  const SLIDE_ELEMENT = document.createElement('div')\n  const SLIDE_ELEMENT_CONTENT = document.createElement('div')\n\n  const GROUP = state.GROUPS[state.activeGroup]\n  const TOTAL_TRIGGER_ELEMENTS = GROUP.triggerElements.length\n\n  SLIDE_ELEMENT.className = 'parvus__slide'\n  SLIDE_ELEMENT.style.cssText = `\n    position: absolute;\n    left: ${index * 100}%;\n  `\n  SLIDE_ELEMENT.setAttribute('aria-hidden', 'true')\n\n  // Add accessibility attributes if gallery has multiple slides\n  if (TOTAL_TRIGGER_ELEMENTS > 1) {\n    SLIDE_ELEMENT.setAttribute('role', 'group')\n    SLIDE_ELEMENT.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`)\n  }\n\n  SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT)\n  FRAGMENT.appendChild(SLIDE_ELEMENT)\n\n  GROUP.sliderElements[index] = SLIDE_ELEMENT\n\n  // Insert the slide element based on index position\n  if (index >= state.currentIndex) {\n    // Insert the slide element after the current slide\n    const NEXT_SLIDE_INDEX = getNextSlideIndex(state, index)\n\n    if (NEXT_SLIDE_INDEX !== -1) {\n      GROUP.sliderElements[NEXT_SLIDE_INDEX].before(SLIDE_ELEMENT)\n    } else {\n      GROUP.slider.appendChild(SLIDE_ELEMENT)\n    }\n  } else {\n    // Insert the slide element before the current slide\n    const PREVIOUS_SLIDE_INDEX = getPreviousSlideIndex(state, index)\n\n    if (PREVIOUS_SLIDE_INDEX !== -1) {\n      GROUP.sliderElements[PREVIOUS_SLIDE_INDEX].after(SLIDE_ELEMENT)\n    } else {\n      GROUP.slider.prepend(SLIDE_ELEMENT)\n    }\n  }\n}\n\n/**\n * Update counter\n *\n * @param {Object} state - The application state\n * @returns {void}\n */\nexport const updateCounter = (state) => {\n  state.counter.textContent = `${state.currentIndex + 1}/${state.GROUPS[state.activeGroup].triggerElements.length}`\n}\n\n/**\n * Update Attributes\n *\n * @param {Object} state - The application state\n * @returns {void}\n */\nexport const updateAttributes = (state) => {\n  const TRIGGER_ELEMENTS = state.GROUPS[state.activeGroup].triggerElements\n  const TOTAL_TRIGGER_ELEMENTS = TRIGGER_ELEMENTS.length\n\n  const SLIDER = state.GROUPS[state.activeGroup].slider\n  const SLIDER_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements\n\n  const IS_DRAGGABLE = SLIDER.classList.contains('parvus__slider--is-draggable')\n\n  // Add draggable class if necessary\n  if ((state.config.simulateTouch && state.config.swipeClose && !IS_DRAGGABLE) || (state.config.simulateTouch && TOTAL_TRIGGER_ELEMENTS > 1 && !IS_DRAGGABLE)) {\n    SLIDER.classList.add('parvus__slider--is-draggable')\n  } else {\n    SLIDER.classList.remove('parvus__slider--is-draggable')\n  }\n\n  // Add extra output for screen reader if there is more than one slide\n  if (TOTAL_TRIGGER_ELEMENTS > 1) {\n    SLIDER.setAttribute('role', 'region')\n    SLIDER.setAttribute('aria-roledescription', 'carousel')\n    SLIDER.setAttribute('aria-label', state.config.l10n.sliderLabel)\n\n    SLIDER_ELEMENTS.forEach((sliderElement, index) => {\n      sliderElement.setAttribute('role', 'group')\n      sliderElement.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`)\n    })\n  } else {\n    SLIDER.removeAttribute('role')\n    SLIDER.removeAttribute('aria-roledescription')\n    SLIDER.removeAttribute('aria-label')\n\n    SLIDER_ELEMENTS.forEach((sliderElement) => {\n      sliderElement.removeAttribute('role')\n      sliderElement.removeAttribute('aria-label')\n    })\n  }\n\n  // Show or hide buttons\n  if (TOTAL_TRIGGER_ELEMENTS === 1) {\n    state.counter.setAttribute('aria-hidden', 'true')\n\n    state.previousButton.setAttribute('aria-hidden', 'true')\n\n    state.nextButton.setAttribute('aria-hidden', 'true')\n  } else {\n    state.counter.removeAttribute('aria-hidden')\n\n    state.previousButton.removeAttribute('aria-hidden')\n\n    state.nextButton.removeAttribute('aria-hidden')\n  }\n}\n\n/**\n * Update slider navigation status\n *\n * @param {Object} state - The application state\n * @returns {void}\n */\nexport const updateSliderNavigationStatus = (state) => {\n  const { triggerElements } = state.GROUPS[state.activeGroup]\n  const TOTAL_TRIGGER_ELEMENTS = triggerElements.length\n\n  if (TOTAL_TRIGGER_ELEMENTS <= 1) {\n    return\n  }\n\n  // Determine navigation state\n  const FIRST_SLIDE = state.currentIndex === 0\n  const LAST_SLIDE = state.currentIndex === TOTAL_TRIGGER_ELEMENTS - 1\n\n  // Set previous button state\n  const PREV_DISABLED = FIRST_SLIDE ? 'true' : null\n\n  if ((state.previousButton.getAttribute('aria-disabled') === 'true') !== !!PREV_DISABLED) {\n    PREV_DISABLED\n      ? state.previousButton.setAttribute('aria-disabled', 'true')\n      : state.previousButton.removeAttribute('aria-disabled')\n  }\n\n  // Set next button state\n  const NEXT_DISABLED = LAST_SLIDE ? 'true' : null\n\n  if ((state.nextButton.getAttribute('aria-disabled') === 'true') !== !!NEXT_DISABLED) {\n    NEXT_DISABLED\n      ? state.nextButton.setAttribute('aria-disabled', 'true')\n      : state.nextButton.removeAttribute('aria-disabled')\n  }\n}\n"
  },
  {
    "path": "src/js/ui/zoom-indicator.js",
    "content": "/**\n * Add zoom indicator to element\n *\n * @param {HTMLElement} el - The element to add the zoom indicator to\n * @param {Object} config - Options object\n */\nexport const addZoomIndicator = (el, config) => {\n  if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') === null) {\n    const LIGHTBOX_INDICATOR_ICON = document.createElement('div')\n\n    LIGHTBOX_INDICATOR_ICON.className = 'parvus-zoom__indicator'\n    LIGHTBOX_INDICATOR_ICON.innerHTML = config.lightboxIndicatorIcon\n\n    el.appendChild(LIGHTBOX_INDICATOR_ICON)\n  }\n}\n\n/**\n * Remove zoom indicator for element\n *\n * @param {HTMLElement} el - The element to remove the zoom indicator to\n */\nexport const removeZoomIndicator = (el) => {\n  if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') !== null) {\n    const LIGHTBOX_INDICATOR_ICON = el.querySelector('.parvus-zoom__indicator')\n\n    el.removeChild(LIGHTBOX_INDICATOR_ICON)\n  }\n}\n"
  },
  {
    "path": "src/l10n/de.js",
    "content": "export default {\n  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.',\n  lightboxLoadingIndicatorLabel: 'Bild wird geladen',\n  lightboxLoadingError: 'Das angeforderte Bild kann nicht geladen werden.',\n  controlsLabel: 'Steuerungen',\n  previousButtonLabel: 'Vorheriges Bild',\n  nextButtonLabel: 'Nächstes Bild',\n  closeButtonLabel: 'Dialogfenster schließen',\n  sliderLabel: 'Bilder',\n  slideLabel: 'Bild'\n}\n"
  },
  {
    "path": "src/l10n/en.js",
    "content": "export default {\n  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.',\n  lightboxLoadingIndicatorLabel: 'Image loading',\n  lightboxLoadingError: 'The requested image cannot be loaded.',\n  controlsLabel: 'Controls',\n  previousButtonLabel: 'Previous image',\n  nextButtonLabel: 'Next image',\n  closeButtonLabel: 'Close dialog window',\n  sliderLabel: 'Images',\n  slideLabel: 'Image'\n}\n"
  },
  {
    "path": "src/l10n/fr.js",
    "content": "export default {\r\n  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.',\r\n  lightboxLoadingIndicatorLabel: 'Chargement de l\\'image',\r\n  lightboxLoadingError: 'L\\'image demandée ne peut pas être chargée',\r\n  controlsLabel: 'Contrôles',\r\n  previousButtonLabel: 'Image précédente',\r\n  nextButtonLabel: 'Image suivante',\r\n  closeButtonLabel: 'Fermer la fenêtre de dialogue',\r\n  sliderLabel: 'Images',\r\n  slideLabel: 'Image'\r\n}"
  },
  {
    "path": "src/l10n/it.js",
    "content": "export default {\n  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.',\n  lightboxLoadingIndicatorLabel: 'Caricamento immagine',\n  lightboxLoadingError: 'Impossibile caricare l\\'immagine richiesta.',\n  controlsLabel: 'Controlli',\n  previousButtonLabel: 'Immagine precedente',\n  nextButtonLabel: 'Immagine successiva',\n  closeButtonLabel: 'Chiudi finestra di dialogo',\n  sliderLabel: 'Immagini',\n  slideLabel: 'Immagine'\n}\n"
  },
  {
    "path": "src/l10n/nl.js",
    "content": "export default {\n  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.',\n  lightboxLoadingIndicatorLabel: 'Afbeelding wordt geladen',\n  lightboxLoadingError: 'De gevraagde afbeelding kan niet worden geladen.',\n  controlsLabel: 'Bedieningselementen',\n  previousButtonLabel: 'Vorige afbeelding',\n  nextButtonLabel: 'Volgende afbeelding',\n  closeButtonLabel: 'Sluit dialoogvenster',\n  sliderLabel: 'Afbeeldingen',\n  slideLabel: 'Afbeelding'\n}\n"
  },
  {
    "path": "src/scss/parvus.scss",
    "content": ":root {\n  // Transition\n  --parvus-transition-duration: 0.3s;\n  --parvus-transition-timing-function: cubic-bezier(0.62, 0.16, 0.13, 1.01);\n\n  // Overlay\n  --parvus-background-color: hsl(23deg 44% 96%);\n  --parvus-color: hsl(228deg 24% 23%);\n\n  // Button\n  --parvus-btn-background-color: hsl(228deg 24% 23%);\n  --parvus-btn-color:  hsl(0deg 0% 100%);\n  --parvus-btn-hover-background-color: hsl(229deg 24% 33%);\n  --parvus-btn-hover-color: hsl(0deg 0% 100%);\n  --parvus-btn-disabled-background-color: hsl(229deg 24% 33% / 60%);\n  --parvus-btn-disabled-color: hsl(0deg 0% 100%);\n\n  // Caption\n  --parvus-caption-background-color: transparent;\n  --parvus-caption-color: hsl(228deg 24% 23%);\n\n  // Copyright\n  --parvus-copyright-background-color: hsl(0deg 0% 100% / 80%);\n  --parvus-copyright-color: hsl(228deg 24% 23%);\n\n  // Loading error\n  --parvus-loading-error-background-color: hsl(0deg 0% 100%);\n  --parvus-loading-error-color: hsl(228deg 24% 23%);\n\n  // Loader\n  --parvus-loader-background-color: hsl(23deg 40% 96%);\n  --parvus-loader-color: hsl(228deg 24% 23%);\n}\n\n::view-transition-group(lightboximage) {\n  animation-duration: var(--parvus-transition-duration);\n  animation-timing-function: var(--parvus-transition-timing-function);\n  z-index: 7;\n}\n\n::view-transition-group(toolbar) {\n\tz-index: 8;\n}\n\nbody:has(.parvus[open]) {\n  touch-action: none;\n}\n\n/**\n * Parvus trigger\n *\n */\n.parvus-trigger:has(img) {\n  display: block;\n  position: relative;\n\n\n  & .parvus-zoom__indicator {\n    align-items: center;\n    background-color: var(--parvus-btn-background-color);\n    color: var(--parvus-btn-color);\n    display: flex;\n    justify-content: center;\n    padding: 0.5rem;\n    position: absolute;\n    inset-inline-end: 0.5rem;\n    inset-block-start: 0.5rem;\n  }\n\n  & img {\n    display: block;\n  }\n}\n\n/**\n * Parvus\n *\n */\n.parvus {\n  background-color: transparent;\n  block-size: 100%;\n  border: 0;\n  box-sizing: border-box;\n  color: var(--parvus-color);\n  contain: strict;\n  inline-size: 100%;\n  inset: 0;\n  margin: 0;\n  max-block-size: unset;\n  max-inline-size: unset;\n  overflow: hidden;\n  overscroll-behavior: contain;\n  padding: 0;\n  position: fixed;\n\n  &::backdrop {\n    display:none;\n  }\n\n  & *,\n  & *::before,\n  & *::after {\n    box-sizing: border-box;\n  }\n\n  &__overlay {\n    background-color: var(--parvus-background-color);\n    color: var(--parvus-color);\n    inset: 0;\n    position: absolute;\n  }\n\n  &__slider {\n    inset: 0;\n    position: absolute;\n    transform: translateZ(0);\n\n    @media screen and (prefers-reduced-motion: no-preference) {\n\n      &--animate:not(&--is-dragging) {\n        transition: transform var(--parvus-transition-duration) var(--parvus-transition-timing-function);\n        will-change: transform;\n      }\n    }\n\n    &--is-draggable {\n      cursor: grab;\n      touch-action: pan-y pinch-zoom;\n    }\n\n    &--is-dragging {\n      cursor: grabbing;\n      touch-action: none;\n    }\n  }\n\n  &__slide {\n    block-size: 100%;\n    contain: layout;\n    display: grid;\n    inline-size: 100%;\n    padding-block: 1rem;\n    padding-inline: 1rem;\n    place-items: center;\n\n\n    & img {\n      block-size: auto;\n      display: block;\n      inline-size: auto;\n      margin-inline: auto;\n      transform: translateZ(0);\n    }\n  }\n\n  &__content {\n    position: relative;\n\n\n    &--error {\n      background-color: var(--parvus-loading-error-background-color);\n      color: var(--parvus-loading-error-color);\n      padding-block: 0.5rem;\n      padding-inline: 1rem;\n    }\n  }\n\n  &__caption {\n    background-color: var(--parvus-caption-background-color);\n    color: var(--parvus-caption-color);\n    padding-block-start: 0.5rem;\n    text-align: start;\n  }\n\n  &__copyright {\n    background-color: var(--parvus-copyright-background-color);\n    color: var(--parvus-copyright-color);\n    inset-block-end: 0;\n    inset-inline-end: 0;\n    padding-inline: 0.25rem;\n    position: absolute;\n  }\n\n  &__loader {\n    display: inline-block;\n    block-size: 6.25rem;\n    inset-inline-start: 50%;\n    position: absolute;\n    inset-block-start: 50%;\n    transform: translate(-50%, -50%);\n    inline-size: 6.25rem;\n\n    &::before {\n      animation: spin 1s infinite linear;\n      border-radius: 100%;\n      border: 0.25rem solid var(--parvus-loader-background-color);\n      border-block-start-color: var(--parvus-loader-color);\n      content: '';\n      inset: 0;\n      position: absolute;\n      z-index: 1;\n    }\n  }\n\n  &__toolbar {\n    align-items: center;\n    display: flex;\n    inset-block-start: 1rem;\n    inset-inline: 1rem;\n    justify-content: space-between;\n    pointer-events: none;\n    position: absolute;\n    view-transition-name: toolbar;\n    z-index: 8;\n\n\n    & > * {\n      pointer-events: auto;\n    }\n  }\n\n  &__controls {\n    align-items: center;\n    display: flex;\n    gap: 0.5rem;\n  }\n\n  &__btn {\n    appearance: none;\n    background-color: var(--parvus-btn-background-color);\n    background-image: none;\n    border-radius: 0;\n    border: 0.0625rem solid transparent;\n    color: var(--parvus-btn-color);\n    cursor: pointer;\n    display: flex;\n    font: inherit;\n    padding: 0.3125rem;\n    position: relative;\n    touch-action: manipulation;\n    will-change: transform, opacity;\n    z-index: 7;\n\n    &:hover,\n    &:focus-visible {\n      background-color: var(--parvus-btn-hover-background-color);\n      color: var(--parvus-btn-hover-color);\n    }\n\n\n    &--previous {\n      inset-inline-start: 0;\n      position: absolute;\n      inset-block-start: calc(50svh - 1rem); // 50svh - paddingTop from .parvus__slide\n      transform: translateY(-50%);\n    }\n\n    &--next {\n      position: absolute;\n      inset-inline-end: 0;\n      inset-block-start: calc(50svh - 1rem); // 50svh - paddingTop from .parvus__slide\n      transform: translateY(-50%);\n    }\n\n    & svg {\n      pointer-events: none;\n    }\n\n    &[aria-hidden='true'] {\n      display: none;\n    }\n\n    &[aria-disabled='true'] {\n      background-color: var(--parvus-btn-disabled-background-color);\n      color: var(--parvus-btn-disabled-color);\n    }\n  }\n\n  &__counter {\n    position: relative;\n    z-index: 7;\n\n    &[aria-hidden='true'] {\n      display: none;\n    }\n  }\n\n  @media screen and (prefers-reduced-motion: no-preference) {\n\n    &__overlay,\n    &__counter,\n    &__btn,\n    &__caption,\n    &__copyright {\n      transition: transform var(--parvus-transition-duration) var(--parvus-transition-timing-function), opacity var(--parvus-transition-duration) var(--parvus-transition-timing-function);\n      will-change: transform, opacity;\n    }\n\n    &__copyright {\n      transition-delay: var(--parvus-transition-duration);\n\n      .parvus--is-closing &,\n      .parvus--is-vertical-closing &,\n      .parvus--is-zooming & {\n        transition-delay: 0s;\n        transition-duration: 0s;\n      }\n    }\n\n    &--is-opening,\n    &--is-closing {\n\n\n\n      & .parvus__overlay,\n      & .parvus__counter,\n      & .parvus__btn,\n      & .parvus__caption,\n      & .parvus__copyright {\n        opacity: 0;\n      }\n    }\n\n    &--is-vertical-closing,\n    &--is-zooming {\n\n\n\n      & .parvus__counter,\n      & .parvus__btn:not(.parvus__btn--previous, .parvus__btn--next) {\n        transform: translateY(-100%);\n        opacity: 0;\n      }\n\n      & .parvus__btn--previous {\n        transform: translate(-100%, -50%);\n        opacity: 0;\n      }\n\n      & .parvus__btn--next {\n        transform: translate(100%, -50%);\n        opacity: 0;\n      }\n\n      & .parvus__caption {\n        transform: translateY(100%);\n        opacity: 0;\n      }\n\n      & .parvus__copyright {\n        opacity: 0;\n      }\n    }\n  }\n}\n\n@keyframes spin {\n\n  from {\n    transform: rotate(0deg);\n  }\n\n  to {\n    transform: rotate(360deg);\n  }\n}\n"
  },
  {
    "path": "test/test.html",
    "content": "<!DOCTYPE html>\r\n<html lang=\"en\">\r\n\r\n<head>\r\n  <meta charset=\"UTF-8\">\r\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\r\n  <title>Parvus - Feature Showcase</title>\r\n\r\n  <link href=\"../dist/css/parvus.min.css\" rel=\"stylesheet\">\r\n  <style>\r\n    * {\r\n      margin: 0;\r\n      padding: 0;\r\n    }\r\n\r\n    *,\r\n    *::before,\r\n    *::after {\r\n      box-sizing: border-box;\r\n    }\r\n\r\n    body {\r\n      font-family: system-ui, -apple-system, sans-serif;\r\n      line-height: 1.6;\r\n      padding: 2rem;\r\n    }\r\n\r\n    h1, h2, h3 {\r\n      margin-top: 2rem;\r\n    }\r\n\r\n    .gallery {\r\n      align-items: start;\r\n      display: grid;\r\n      grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));\r\n      gap: 1rem;\r\n      margin: 1rem 0;\r\n    }\r\n\r\n    .gallery img {\r\n      width: 100%;\r\n      height: auto;\r\n      display: block;\r\n    }\r\n\r\n    code {\r\n      background: #f4f4f4;\r\n      padding: 0.2rem 0.4rem;\r\n      border-radius: 3px;\r\n      font-family: monospace;\r\n    }\r\n\r\n    .text-links {\r\n      align-items: start;\r\n      display: flex;\r\n      flex-direction: column;\r\n      gap: 0.5rem;\r\n    }\r\n  </style>\r\n</head>\r\n\r\n<body>\r\n  <h1>Parvus - Feature Showcase</h1>\r\n  <p>This page demonstrates all features and options of Parvus.</p>\r\n\r\n  <h2>1. Basic Usage</h2>\r\n  <p>Simple image with no caption or grouping.</p>\r\n  <div class=\"gallery\">\r\n    <a href=\"./images/4-1200.webp\" class=\"lightbox\">\r\n      <img src=\"./images/4-370.webp\"\r\n           alt=\"2 glasses filled with Mojito Cocktail on a dark table.\"\r\n           width=\"370\"\r\n           height=\"657\">\r\n    </a>\r\n  </div>\r\n\r\n  <h2>2. Captions - Direct Attribute</h2>\r\n  <p>Using <code>data-caption</code> attribute.</p>\r\n  <div class=\"gallery\">\r\n    <a href=\"./images/3-1200.webp\"\r\n       class=\"lightbox\"\r\n       data-caption=\"A day trip to Leiden on our vacation in the Netherlands.\">\r\n      <img src=\"./images/3-370.webp\"\r\n           alt=\"The river 'Oude Rijn' in Leiden.\"\r\n           width=\"370\"\r\n           height=\"208\">\r\n    </a>\r\n  </div>\r\n\r\n  <h2>3. Captions - Reference by ID</h2>\r\n  <p>Using <code>data-caption-id</code> to reference an external element.</p>\r\n  <div class=\"gallery\">\r\n    <figure>\r\n      <a href=\"./images/1-1200.webp\"\r\n         class=\"lightbox\"\r\n         data-caption-id=\"caption-1\">\r\n        <img src=\"./images/1-370.webp\"\r\n             alt=\"Picturesque house facades in Leiden.\"\r\n             width=\"370\"\r\n             height=\"208\">\r\n      </a>\r\n      <figcaption id=\"caption-1\">\r\n        Picturesque house facades in Leiden, a city in South Holland, Netherlands.\r\n      </figcaption>\r\n    </figure>\r\n  </div>\r\n\r\n  <h2>4. Copyright - Direct Attribute</h2>\r\n  <p>Using <code>data-copyright</code> attribute (new feature!).</p>\r\n  <div class=\"gallery\">\r\n    <a href=\"./images/2-1200.webp\"\r\n       class=\"lightbox\"\r\n       data-caption=\"Beautiful architecture in Leiden\"\r\n       data-copyright=\"© 2026 Photography Studio\">\r\n      <img src=\"./images/2-370.webp\"\r\n           alt=\"Picturesque house facades in Leiden.\"\r\n           width=\"370\"\r\n           height=\"657\">\r\n    </a>\r\n  </div>\r\n\r\n  <h2>5. Copyright - Reference by ID</h2>\r\n  <p>Using <code>data-copyright-id</code> to reference an external element.</p>\r\n  <div class=\"gallery\">\r\n    <a href=\"./images/8-1200.webp\"\r\n       class=\"lightbox\"\r\n       data-caption=\"Street view\"\r\n       data-copyright-id=\"copyright-1\">\r\n      <img src=\"./images/8-370.webp\"\r\n           alt=\"Street scene\"\r\n           width=\"370\"\r\n           height=\"277\">\r\n    </a>\r\n  </div>\r\n  <small id=\"copyright-1\" hidden>© 2026 Street Photography Collection</small>\r\n\r\n  <h2>6. Gallery with <code>data-group</code></h2>\r\n  <p>Images grouped together using <code>data-group=\"Netherlands\"</code>.</p>\r\n  <div class=\"gallery\">\r\n    <a href=\"./images/1-1200.webp\"\r\n       class=\"lightbox\"\r\n       data-group=\"Netherlands\"\r\n       data-caption=\"Leiden architecture\"\r\n       data-copyright=\"© 2026 Netherlands Collection\">\r\n      <img src=\"./images/1-370.webp\"\r\n           alt=\"Leiden facades\"\r\n           width=\"370\"\r\n           height=\"208\">\r\n    </a>\r\n\r\n    <a href=\"./images/2-1200.webp\"\r\n       class=\"lightbox\"\r\n       data-group=\"Netherlands\"\r\n       data-caption=\"Historic buildings\"\r\n       data-copyright=\"© 2026 Netherlands Collection\">\r\n      <img src=\"./images/2-370.webp\"\r\n           alt=\"Leiden houses\"\r\n           width=\"370\"\r\n           height=\"657\">\r\n    </a>\r\n\r\n    <a href=\"./images/3-1200.webp\"\r\n       class=\"lightbox\"\r\n       data-group=\"Netherlands\"\r\n       data-caption=\"Oude Rijn river view\"\r\n       data-copyright=\"© 2026 Netherlands Collection\">\r\n      <img src=\"./images/3-370.webp\"\r\n           alt=\"River in Leiden\"\r\n           width=\"370\"\r\n           height=\"208\">\r\n    </a>\r\n  </div>\r\n\r\n  <h2>7. Gallery with <code>gallerySelector</code></h2>\r\n  <p>Images automatically grouped by parent selector (no <code>data-group</code> needed).</p>\r\n  <div class=\"gallery automatic-gallery\">\r\n    <a href=\"./images/8-1200.webp\" class=\"lightbox-gallery\">\r\n      <img src=\"./images/8-370.webp\"\r\n           alt=\"Street scene 1\"\r\n           width=\"370\"\r\n           height=\"277\">\r\n    </a>\r\n\r\n    <a href=\"./images/9-1200.webp\" class=\"lightbox-gallery\">\r\n      <img src=\"./images/9-370.webp\"\r\n           alt=\"Street scene 2\"\r\n           width=\"370\"\r\n           height=\"277\">\r\n    </a>\r\n  </div>\r\n\r\n  <h2>8. Responsive Images with <code>srcset</code></h2>\r\n  <p>Using <code>data-srcset</code> for responsive images.</p>\r\n  <div class=\"gallery\">\r\n    <a href=\"./images/1-1200.webp\"\r\n       class=\"lightbox\"\r\n       data-srcset=\"./images/1-1200.webp 1200w, ./images/1-1000.webp 1000w, ./images/1-700.webp 700w, ./images/1-500.webp 500w\"\r\n       data-caption=\"Responsive image with srcset\">\r\n      <img src=\"./images/1-370.webp\"\r\n           alt=\"Leiden with responsive sources\"\r\n           width=\"370\"\r\n           height=\"208\">\r\n    </a>\r\n  </div>\r\n\r\n  <h2>9. Text Links</h2>\r\n  <p>Opening images from text links (no thumbnail image).</p>\r\n  <div class=\"text-links\">\r\n    <a href=\"./images/1-1200.webp\"\r\n       class=\"lightbox\"\r\n       data-group=\"textlinks\"\r\n       data-caption=\"Text link example 1\">\r\n      View Photo 1\r\n    </a>\r\n\r\n    <a href=\"./images/2-1200.webp\"\r\n       class=\"lightbox\"\r\n       data-group=\"textlinks\"\r\n       data-caption=\"Text link example 2\">\r\n      View Photo 2\r\n    </a>\r\n\r\n    <a href=\"./images/3-1200.webp\"\r\n       class=\"lightbox\"\r\n       data-group=\"textlinks\"\r\n       data-caption=\"Text link example 3\">\r\n      View Photo 3\r\n    </a>\r\n  </div>\r\n\r\n  <h2>10. Button Elements</h2>\r\n  <p>Using buttons instead of links with <code>data-target</code> attribute.</p>\r\n  <div>\r\n    <button class=\"lightbox-button\"\r\n            data-target=\"./images/4-1200.webp\"\r\n            data-caption=\"Opened via button element\"\r\n            data-copyright=\"© 2026 Button Demo\">\r\n      Open Image via Button\r\n    </button>\r\n  </div>\r\n\r\n  <h2>11. Mixed Groups</h2>\r\n  <p>Different groups on the same page.</p>\r\n  <div class=\"gallery\">\r\n    <a href=\"./images/8-1200.webp\"\r\n       class=\"lightbox\"\r\n       data-group=\"groupA\"\r\n       data-caption=\"Group A - Image 1\">\r\n      <img src=\"./images/8-370.webp\"\r\n           alt=\"Group A\"\r\n           width=\"370\"\r\n           height=\"277\">\r\n    </a>\r\n\r\n    <a href=\"./images/9-1200.webp\"\r\n       class=\"lightbox\"\r\n       data-group=\"groupB\"\r\n       data-caption=\"Group B - Image 1\">\r\n      <img src=\"./images/9-370.webp\"\r\n           alt=\"Group B\"\r\n           width=\"370\"\r\n           height=\"277\">\r\n    </a>\r\n\r\n    <a href=\"./images/4-1200.webp\"\r\n       class=\"lightbox\"\r\n       data-group=\"groupA\"\r\n       data-caption=\"Group A - Image 2\">\r\n      <img src=\"./images/4-370.webp\"\r\n           alt=\"Group A second\"\r\n           width=\"370\"\r\n           height=\"657\">\r\n    </a>\r\n  </div>\r\n\r\n  <h2>12. Localization</h2>\r\n  <p>Using different language files. This example uses German (<code>l10n: de</code>).</p>\r\n  <div class=\"gallery\">\r\n    <a href=\"./images/4-1200.webp\"\r\n       class=\"lightbox-german\"\r\n       data-caption=\"Diese Galerie nutzt die deutsche Sprachdatei\">\r\n      <img src=\"./images/4-370.webp\"\r\n           alt=\"Mojito Cocktail\"\r\n           width=\"370\"\r\n           height=\"657\">\r\n    </a>\r\n  </div>\r\n\r\n  <h2>13. Plugin Example</h2>\r\n  <p>Custom plugin that adds a button and tracks slide changes (check console). <strong>Note:</strong> The plugin is only registered for these specific images, not for the other galleries above.</p>\r\n  <div class=\"gallery\">\r\n    <a href=\"./images/1-1200.webp\"\r\n       class=\"lightbox-plugin\"\r\n       data-group=\"plugin-demo\"\r\n       data-caption=\"Plugin demo - Slide 1\">\r\n      <img src=\"./images/1-370.webp\"\r\n           alt=\"Plugin demo 1\"\r\n           width=\"370\"\r\n           height=\"208\">\r\n    </a>\r\n\r\n    <a href=\"./images/2-1200.webp\"\r\n       class=\"lightbox-plugin\"\r\n       data-group=\"plugin-demo\"\r\n       data-caption=\"Plugin demo - Slide 2\">\r\n      <img src=\"./images/2-370.webp\"\r\n           alt=\"Plugin demo 2\"\r\n           width=\"370\"\r\n           height=\"657\">\r\n    </a>\r\n\r\n    <a href=\"./images/3-1200.webp\"\r\n       class=\"lightbox-plugin\"\r\n       data-group=\"plugin-demo\"\r\n       data-caption=\"Plugin demo - Slide 3\">\r\n      <img src=\"./images/3-370.webp\"\r\n           alt=\"Plugin demo 3\"\r\n           width=\"370\"\r\n           height=\"208\">\r\n    </a>\r\n  </div>\r\n\r\n  <script type=\"module\">\r\n    import Parvus from '../dist/js/parvus.esm.js'\r\n    import de from '../src/l10n/de.js'\r\n\r\n    // Example plugin from README\r\n    const MyPlugin = {\r\n      name: 'MyPlugin',\r\n\r\n      install(parvus, options) {\r\n        console.log('MyPlugin installed with options:', options)\r\n\r\n        // Add a custom button on init\r\n        parvus.addHook('afterInit', ({ state }) => {\r\n          const btn = document.createElement('button')\r\n\r\n          btn.classList.add('parvus__btn')\r\n          btn.classList.add('parvus__btn--my-plugin')\r\n          btn.innerHTML = `\r\n            <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1\" stroke=\"currentColor\">\r\n              <path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"></path>\r\n              <circle cx=\"12\" cy=\"12\" r=\"3\"></circle>\r\n            </svg>\r\n          `\r\n          btn.type = 'button'\r\n          btn.title = 'Custom Plugin Button'\r\n          btn.setAttribute('aria-label', 'Custom Plugin Button')\r\n\r\n          btn.addEventListener('click', () => {\r\n            console.log('Custom plugin button clicked!')\r\n            alert('Custom plugin button works! Current slide: ' + state.currentIndex)\r\n          })\r\n\r\n          // Add to controls as first element\r\n          if (state.controls) {\r\n            state.controls.prepend(btn)\r\n          }\r\n        })\r\n\r\n        // Track slide changes\r\n        parvus.addHook('slideChange', ({ index, oldIndex }) => {\r\n          console.log(`Changed from slide ${oldIndex} to ${index}`)\r\n        })\r\n\r\n        // Track open events\r\n        parvus.addHook('afterOpen', ({ element, state }) => {\r\n          console.log('Lightbox opened for element:', element)\r\n        })\r\n\r\n        // Track close events\r\n        parvus.addHook('afterClose', ({ state }) => {\r\n          console.log('Lightbox closed')\r\n        })\r\n      }\r\n    }\r\n\r\n    // Initialize Parvus for regular lightbox links\r\n    const prvs = new Parvus({\r\n      selector: '.lightbox'\r\n    })\r\n\r\n    // Initialize Parvus for plugin demo (separate instance with plugin)\r\n    const prvsPlugin = new Parvus({\r\n      selector: '.lightbox-plugin'\r\n    })\r\n\r\n    // Register the custom plugin ONLY for prvsPlugin instance\r\n    prvsPlugin.use(MyPlugin, {\r\n      customOption: 'demo value'\r\n    })\r\n\r\n    // Initialize Parvus for gallery with gallerySelector\r\n    const prvsGallery = new Parvus({\r\n      selector: '.lightbox-gallery',\r\n      gallerySelector: '.automatic-gallery'\r\n    })\r\n\r\n    // Initialize Parvus for buttons\r\n    const prvsButtons = new Parvus({\r\n      selector: '.lightbox-button'\r\n    })\r\n\r\n    // Initialize Parvus with German language\r\n    const prvsGerman = new Parvus({\r\n      selector: '.lightbox-german',\r\n      l10n: de\r\n    })\r\n\r\n    // Log registered plugins\r\n    console.log('Registered plugins (prvs):', prvs.getPlugins())\r\n    console.log('Registered plugins (prvsPlugin):', prvsPlugin.getPlugins())\r\n    console.log('Registered plugins (prvsGallery):', prvsGallery.getPlugins())\r\n    console.log('Registered plugins (prvsButtons):', prvsButtons.getPlugins())\r\n    console.log('Registered plugins (prvsGerman):', prvsGerman.getPlugins())\r\n  </script>\r\n</body>\r\n\r\n</html>\r\n"
  }
]