Full Code of deoostfreese/Parvus for AI

main 3a3ddefc5d5d cached
32 files
249.1 KB
64.7k tokens
51 symbols
1 requests
Download .txt
Showing preview only (261K chars total). Download the full file or copy to clipboard to get everything.
Repository: deoostfreese/Parvus
Branch: main
Commit: 3a3ddefc5d5d
Files: 32
Total size: 249.1 KB

Directory structure:
gitextract_wwaefxjx/

├── .github/
│   └── FUNDING.yml
├── .gitignore
├── .stylelintrc
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── dist/
│   ├── css/
│   │   └── parvus.css
│   └── js/
│       ├── parvus.esm.js
│       └── parvus.js
├── package.json
├── rollup.config.js
├── src/
│   ├── js/
│   │   ├── core/
│   │   │   ├── config.js
│   │   │   ├── events.js
│   │   │   ├── navigation.js
│   │   │   ├── plugins.js
│   │   │   ├── state.js
│   │   │   └── utils.js
│   │   ├── handlers/
│   │   │   ├── gestures.js
│   │   │   ├── images.js
│   │   │   ├── keyboard.js
│   │   │   └── pointer.js
│   │   ├── helpers/
│   │   │   └── dom.js
│   │   ├── parvus.js
│   │   └── ui/
│   │       ├── lightbox.js
│   │       └── zoom-indicator.js
│   ├── l10n/
│   │   ├── de.js
│   │   ├── en.js
│   │   ├── fr.js
│   │   ├── it.js
│   │   └── nl.js
│   └── scss/
│       └── parvus.scss
└── test/
    └── test.html

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms

github: [deoostfrees]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']


================================================
FILE: .gitignore
================================================
.DS_Store
.vscode
node_modules


================================================
FILE: .stylelintrc
================================================
{
  "extends": [
    "stylelint-config-standard-scss"
  ],
  "plugins": [
    "stylelint-scss",
    "stylelint-use-logical"
  ],
  "rules": {
    "at-rule-no-unknown": null,
    "scss/at-rule-no-unknown": true,
    "color-hex-length": "long",
    "comment-whitespace-inside": null,
    "no-descending-specificity": null,
    "shorthand-property-no-redundant-values": [true, {"severity": "warning"}],
    "declaration-no-important": true,
    "no-duplicate-at-import-rules": true,
    "selector-max-id": 0,
    "declaration-block-no-duplicate-properties": true,
    "rule-empty-line-before": ["always-multi-line", {"ignore": ["after-comment"]}],
    "value-keyword-case": "lower",
    "selector-class-pattern": ["^([a-z][a-z0-9]*)(-[a-z0-9]+)*(_[a-z0-9]+)*(__[a-z]((_|-)?[a-z0-9])*)?(--[a-z0-9]((_|-)?[a-z0-9\\\\\\/])*)?$", { "resolveNestedSelectors": true }],
    "declaration-block-no-redundant-longhand-properties": null,
    "csstools/use-logical": true
  }
}


================================================
FILE: CHANGELOG.md
================================================
# Changelog

## [3.1.0] - 2026-04-18

### Added

- Add copyright information to an image e598627 @deoostfrees
- Add plugin system with lifecycle hooks ae8203d 2ea0794 86f1056 @deoostfrees
- Add support for captions via ID reference f6e1b8c @deoostfrees
- Add Italian translations 30c42e2 ea54ca2 @conlaccento
- Add French translations 4d04d8d @slolo2000

### Changed

- Use `hsl()` instead of `hsla()` 4ac8cb6 @deoostfrees
- Modularize codebase and improve maintainability 951d30a @deoostfrees

## [3.0.0] - 2025-03-16

### Added

- Pinch zoom gestures 4a591e7 4a8355a fd4ebf1 4e472ef 49c5b16 d27efd9 @deoostfrees #42
- Option to make the zoom indicator optional e65d5c7 @deoostfrees #62

### Changed

- Use the native HTML `dialog` element e703293 @deoostfrees #60
- Use the View Transitions API for the zoom in/ out animation 11e183f @deoostfrees
- Use pointer events instead of mouse and touch events b4941cf @deoostfrees

### Removed

- **Breaking:** The custom event `detail` property 4ea8e38 @deoostfrees
- The `transitionDuration` option. This option is now also set via the available CSS custom property 11e183f @deoostfrees
- The `transitionTimingFunction` option. This option is now also set via the available CSS custom property 11e183f @deoostfrees
- The `loadEmpty` option. The internal `add` function now creates the lightbox 98e41b5 @deoostfrees
- The custom `close` event. The native HTML `dialog` element has its own `close` event dba4678 @deoostfrees

## [2.6.0] - 2024-06-05

### Changed

- Run `change` event listener for `reducedMotionCheck` only when the lightbox is open 083a0e7 @deoostfrees

### Fixed

- Avoid unintentionally moving the image when dragging 96ff56e @deoostfrees #59
- Relationship between caption and image 76df207 @deoostfrees

## [2.5.3] - 2024-04-27

### Fixed

- Remove optional files field in package.json to include all files via NPM 819e132 @deoostfrees

## [2.5.2] - 2024-04-27

### Fixed

- Language file import afe86dc @deoostfrees #55

## [2.5.1] - 2024-04-10

### Fixed

- Issue if no language options are set 2dbed4a @deoostfrees

## [2.5.0] - 2024-04-07

### Added

- Option to load an empty lightbox (even if there are no elements) 9a180fc @deoostfrees a436a81 @drhino
- Fallback to the default language 39e1ae0 @drhino
- Dutch translation 7476426 @drhino

### Changed

- **Breaking:** Rename some CSS custom properties 8b43c66  8ba1f00 @deoostfrees

### Removed

- Slide animation when first/ last slide is visible 4df766b @deoostfrees #52

## [2.4.0] - 2023-07-20

### Added

- Option to hide the browser scrollbar #47

### Changed

- Added an internal function to create and dispatch a new event
- Disabled buttons are no longer visually hidden
- Focus is no longer moved automatically
- CSS styles are now moved from SVG to the actual elements

### Removed

- Custom typography styles

### Fixed

- Load the srcset before the src, add sizes attribute #49

## [2.3.3] - 2023-05-30

### Fixed

- Animate current image and set focus back to the correct element in the default behavior of the `backFocus` option

## [2.3.2] - 2023-05-30

### Fixed

- Set focus back to the correct element in the default behavior of the `backFocus` option

## [2.3.1] - 2023-05-29

### Fixed

- The navigation buttons' visibility

## [2.3.0] - 2023-05-27

### Added

- Changelog section to keep track of changes
- Necessary outputs for screen reader support
- CSS custom properties for captions and image loading error messages

### Changed

- Replaced the custom `copyObject()` function with the built-in `structuredClone()` method
- Refactored code and comments to improve readability and optimize performance

### Removed

- The option for supported image file types as it is no longer necessary
- The `scrollClose` option

### Fixed

- Non standard URLs can break Parvus #43


================================================
FILE: LICENSE.md
================================================
# The MIT License (MIT)

Copyright (c) 2020-2026 Benjamin de Oostfrees

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


================================================
FILE: README.md
================================================
# Parvus

Overlays suck, but if you need one, consider using Parvus. Parvus is an open source, dependency free image lightbox with the goal of being accessible.

![Screenshot of Parvus. It shows the first picture of a gallery.](https://rqrauhvmra.com/parvus/parvus-3-1.png)

[Open in CodePen](https://codepen.io/collection/DwLBpz)

## Table of Contents

- [Installation](#installation)
  - [Download](#download)
  - [Package Managers](#package-managers)
- [Usage](#usage)
  - [Captions](#captions)
  - [Copyright](#copyright)
  - [Gallery](#gallery)
  - [Responsive Images](#responsive-images)
  - [Localization](#localization)
- [Options](#options)
- [API](#api)
- [Events](#events)
- [Plugins](#plugins)
  - [Using Plugins](#using-plugins)
  - [Creating Plugins](#creating-plugins)
  - [Plugin Hooks](#plugin-hooks)
- [Browser Support](#browser-support)

## Installation

### Download

- CSS:
  - `dist/css/parvus.min.css` (minified) or
  - `dist/css/parvus.css` (un-minified)
- JavaScript:
  - `dist/js/parvus.min.js` (minified) or
  - `dist/js/parvus.js` (un-minified)

Link the `.css` and `.js` files in your HTML:

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Page title</title>

  <!-- CSS -->
  <link href="path/to/parvus.min.css" rel="stylesheet">
</head>
<body>
  <!-- HTML content -->

  <!-- JS -->
  <script src="path/to/parvus.min.js"></script>
</body>
</html>
```

### Package Managers

You can also install Parvus using npm or yarn:

```sh
npm install parvus
```

or

```sh
yarn add parvus
```

After installation, import Parvus into your JavaScript codebase:

```js
import Parvus from 'parvus'
```

Be sure to include the corresponding SCSS or CSS file.

## Usage

Link a thumbnail image with the class `lightbox` to a larger image:

```html
<a href="path/to/image.jpg" class="lightbox">
  <img src="path/to/thumbnail.jpg" alt="">
</a>
```

Initialize the script:

```js
const prvs = new Parvus()
```

### Captions

There are three ways to add a caption to an image:

#### Reference by ID

You can add an ID to your caption element and reference it from the trigger element using the `data-caption-id` attribute.

```html
<figure>
  <a href="path/to/image.jpg" class="lightbox" data-caption-id="caption-1">
    <img src="path/to/thumbnail.jpg" alt="">
  </a>

  <figcaption id="caption-1">
    I'm a caption, and I live outside the link.
  </figcaption>
</figure>
```

#### Direct Attribute

You can add a `data-caption` attribute directly to the trigger element.

```html
<a href="path/to/image.jpg" class="lightbox" data-caption="I'm a simple caption">
  <img src="path/to/thumbnail.jpg" alt="">
</a>
```

#### Child Element

Alternatively, set the option `captionsSelector` to select a caption from a child element's `innerHTML`.

```html
<a href="path/to/image.jpg" class="lightbox">
  <figure class="figure">
    <img src="path/to/thumbnail.jpg" alt="">

    <figcaption class="figure__caption">
      I'm a caption inside a child element
    </figcaption>
  </figure>
</a>
```

```js
const prvs = new Parvus({
  captionsSelector: '.figure__caption',
})
```

### Copyright

There are three ways to add copyright information to an image:

#### Reference by ID

You can add an ID to your copyright element and reference it from the trigger element using the `data-copyright-id` attribute.

```html
<a href="path/to/image.jpg" class="lightbox" data-copyright-id="copyright-1">
  <img src="path/to/thumbnail.jpg" alt="">
</a>

<small id="copyright-1" hidden>
  © 2026 Photographer Name
</small>
```

#### Direct Attribute

You can add a `data-copyright` attribute directly to the trigger element.

```html
<a href="path/to/image.jpg" class="lightbox" data-copyright="© 2026 Photographer Name">
  <img src="path/to/thumbnail.jpg" alt="">
</a>
```

#### Child Element

Alternatively, set the option `copyrightSelector` to select a copyright from a child element's `innerHTML`.

```html
<a href="path/to/image.jpg" class="lightbox">
  <figure class="figure">
    <img src="path/to/thumbnail.jpg" alt="">

    <small class="figure__copyright">
      © 2026 Photographer Name
    </small>
  </figure>
</a>
```

```js
const prvs = new Parvus({
  copyrightSelector: '.figure__copyright',
})
```

### Gallery

To group related images into a set, add a `data-group` attribute:

```html
<a href="path/to/image.jpg" class="lightbox" data-group="Berlin">
  <img src="path/to/thumbnail.jpg" alt="">
</a>

<a href="path/to/image_2.jpg" class="lightbox" data-group="Berlin">
  <img src="path/to/thumbnail_2.jpg" alt="">
</a>

//...

<a href="path/to/image_8.jpg" class="lightbox" data-group="Kassel">
  <img src="path/to/thumbnail_8.jpg" alt="">
</a>
```

Alternatively, set the option `gallerySelector` to group all images with a specific class within a selector:

```html
<div class="gallery">
  <a href="path/to/image.jpg" class="lightbox">
    <img src="path/to/thumbnail.jpg" alt="">
  </a>

  <a href="path/to/image_2.jpg" class="lightbox">
    <img src="path/to/thumbnail_2.jpg" alt="">
  </a>

  // ...
</div>
```

```js
const prvs = new Parvus({
  gallerySelector: '.gallery',
})
```

### Responsive Images

Specify different image sources and sizes using the `data-srcset` and `data-sizes` attributes:

```html
<a href="path/to/image.jpg" class="lightbox"

data-srcset="path/to/small.jpg 700w,
             path/to/medium.jpg 1000w,
             path/to/large.jpg 1200w"

data-sizes="(max-width: 75em) 100vw,
            75em"
>
  <img src="path/to/thumbnail.jpg" alt="">
</a>
```

### Localization

Import the language module and set it as an option for localization:

```js
import de from 'parvus/src/l10n/de'

const prvs = new Parvus({
  l10n: de
})
```

## Options

Customize Parvus by passing an options object when initializing:

```js
const prvs = new Parvus({
  // Clicking outside does not close Parvus
  docClose: false
})
```

Available options include:

```js
{
  // Selector for elements that trigger Parvus
  selector: '.lightbox',

  // Selector for a group of elements combined as a gallery, overrides the `data-group` attribute.
  gallerySelector: null,

  // Display zoom indicator
  zoomIndicator: true,

  // Display captions if available
  captions: true,

  // Selector for the element where the caption is displayed; use "self" for the `a` tag itself.
  captionsSelector: 'self',

  // Attribute to get the caption from
  captionsAttribute: 'data-caption',

  // Display copyright if available
  copyright: true,

  // Selector for the element where the copyright is displayed; use "self" for the `a` tag itself.
  copyrightSelector: 'self',

  // Attribute to get the copyright from
  copyrightAttribute: 'data-copyright',

  // Clicking outside closes Parvus
  docClose: true,

  // Close Parvus by swiping up/down
  swipeClose: true,

  // Accept mouse events like touch events (click and drag to change slides)
  simulateTouch: true,

  // Touch dragging threshold in pixels
  threshold: 100,

  // Hide browser scrollbar
  hideScrollbar: true,

  // Icons
  lightboxIndicatorIcon: '<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>',
  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>',
  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>',
  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>',

  // Localization of strings
  l10n: en
}
```

## API

Parvus provides the following API functions:

| Function | Description |
| --- | --- |
| `open(element)` | Open the specified `element` (DOM element) |
| `close()` | Close Parvus |
| `previous()` | Show the previous image |
| `next()` | Show the next image |
| `select(index)` | Select a slide with the specified `index` (integer) |
| `add(element)` | Add the specified `element` (DOM element) |
| `remove(element)` | Remove the specified `element` (DOM element) |
| `destroy()` | Destroy Parvus |
| `isOpen()` | Check if Parvus is currently open |
| `currentIndex()` | Get the index of the currently displayed slide |
| `use(plugin, options)` | Register a plugin |
| `addHook(hookName, callback)` | Add a hook callback |
| `getPlugins()` | Get list of registered plugins |

## Events

Bind and unbind events using the `.on()` and `.off()` methods:

```js
const prvs = new Parvus()

const listener = () => {
  console.log('eventName happened')
}

// Bind event listener
prvs.on(eventName, listener)

// Unbind event listener
prvs.off(eventName, listener)
```

Available events:

| eventName | Description |
| --- | --- |
| `open` | Triggered after Parvus has opened |
| `select` | Triggered when a slide is selected |
| `close` | Triggered after Parvus has closed |
| `destroy` | Triggered after Parvus has destroyed |

## Plugins

Parvus supports a plugin system that allows you to extend its functionality.

### Using Plugins

To use a plugin, call the `.use()` method after initialization:

```js
import Parvus from 'parvus'
import MyPlugin from './my-plugin.js'

const prvs = new Parvus()

// Register plugin
prvs.use(MyPlugin, {
  // Plugin-specific options
  option1: 'value1',
  option2: 'value2'
})
```

### Creating Plugins

A plugin is an object with a `name` and an `install` function:

```js
const MyPlugin = {
  name: 'MyPlugin',

  install(parvus, options = {}) {
    // Plugin initialization code
    console.log('Plugin installed with options: ', options)
  }
}

export default MyPlugin
```

### Plugin Hooks

Plugins can hook into various lifecycle events:

| Hook Name | When Triggered | Provided Data |
| --- | --- | --- |
| `afterInit` | After lightbox DOM is created (once) | `{ state }` |
| `afterOpen` | After lightbox opens | `{ element, state }` |
| `afterClose` | After lightbox closes | `{ state }` |
| `slideChange` | When slide changes | `{ index, oldIndex, state }` |

Example using hooks:

```js
const MyPlugin = {
  name: 'MyPlugin',

  install(parvus, options) {
    // Add a custom button on init
    parvus.addHook('afterInit', ({ state }) => {
      const btn = document.createElement('button')

      btn.classList.add('parvus__btn')
      btn.classList.add('parvus__btn--my-plugin')
      btn.textContent = 'Custom'
      btn.type = 'button'

      // Add to controls as first element
      if (state.controls) {
        state.controls.prepend(btn)
      }
    })

    // Track slide changes
    parvus.addHook('slideChange', ({ index, oldIndex }) => {
      console.log(`Changed from slide ${oldIndex} to ${index}`)
    })
  }
}
```

## Browser Support

Parvus is supported on the latest versions of the following browsers:

- Chrome
- Edge
- Firefox
- Safari


================================================
FILE: dist/css/parvus.css
================================================
:root {
  --parvus-transition-duration: 0.3s;
  --parvus-transition-timing-function: cubic-bezier(0.62, 0.16, 0.13, 1.01);
  --parvus-background-color: hsl(23deg 44% 96%);
  --parvus-color: hsl(228deg 24% 23%);
  --parvus-btn-background-color: hsl(228deg 24% 23%);
  --parvus-btn-color: hsl(0deg 0% 100%);
  --parvus-btn-hover-background-color: hsl(229deg 24% 33%);
  --parvus-btn-hover-color: hsl(0deg 0% 100%);
  --parvus-btn-disabled-background-color: hsl(229deg 24% 33% / 60%);
  --parvus-btn-disabled-color: hsl(0deg 0% 100%);
  --parvus-caption-background-color: transparent;
  --parvus-caption-color: hsl(228deg 24% 23%);
  --parvus-copyright-background-color: hsl(0deg 0% 100% / 80%);
  --parvus-copyright-color: hsl(228deg 24% 23%);
  --parvus-loading-error-background-color: hsl(0deg 0% 100%);
  --parvus-loading-error-color: hsl(228deg 24% 23%);
  --parvus-loader-background-color: hsl(23deg 40% 96%);
  --parvus-loader-color: hsl(228deg 24% 23%);
}

::view-transition-group(lightboximage) {
  animation-duration: var(--parvus-transition-duration);
  animation-timing-function: var(--parvus-transition-timing-function);
  z-index: 7;
}

::view-transition-group(toolbar) {
  z-index: 8;
}

body:has(.parvus[open]) {
  touch-action: none;
}

/**
 * Parvus trigger
 *
 */
.parvus-trigger:has(img) {
  display: block;
  position: relative;
}
.parvus-trigger:has(img) .parvus-zoom__indicator {
  align-items: center;
  background-color: var(--parvus-btn-background-color);
  color: var(--parvus-btn-color);
  display: flex;
  justify-content: center;
  padding: 0.5rem;
  position: absolute;
  inset-inline-end: 0.5rem;
  inset-block-start: 0.5rem;
}
.parvus-trigger:has(img) img {
  display: block;
}

/**
 * Parvus
 *
 */
.parvus {
  background-color: transparent;
  block-size: 100%;
  border: 0;
  box-sizing: border-box;
  color: var(--parvus-color);
  contain: strict;
  inline-size: 100%;
  inset: 0;
  margin: 0;
  max-block-size: unset;
  max-inline-size: unset;
  overflow: hidden;
  overscroll-behavior: contain;
  padding: 0;
  position: fixed;
}
.parvus::backdrop {
  display: none;
}
.parvus *, .parvus *::before, .parvus *::after {
  box-sizing: border-box;
}
.parvus__overlay {
  background-color: var(--parvus-background-color);
  color: var(--parvus-color);
  inset: 0;
  position: absolute;
}
.parvus__slider {
  inset: 0;
  position: absolute;
  transform: translateZ(0);
}
@media screen and (prefers-reduced-motion: no-preference) {
  .parvus__slider--animate:not(.parvus__slider--is-dragging) {
    transition: transform var(--parvus-transition-duration) var(--parvus-transition-timing-function);
    will-change: transform;
  }
}
.parvus__slider--is-draggable {
  cursor: grab;
  touch-action: pan-y pinch-zoom;
}
.parvus__slider--is-dragging {
  cursor: grabbing;
  touch-action: none;
}
.parvus__slide {
  block-size: 100%;
  contain: layout;
  display: grid;
  inline-size: 100%;
  padding-block: 1rem;
  padding-inline: 1rem;
  place-items: center;
}
.parvus__slide img {
  block-size: auto;
  display: block;
  inline-size: auto;
  margin-inline: auto;
  transform: translateZ(0);
}
.parvus__content {
  position: relative;
}
.parvus__content--error {
  background-color: var(--parvus-loading-error-background-color);
  color: var(--parvus-loading-error-color);
  padding-block: 0.5rem;
  padding-inline: 1rem;
}
.parvus__caption {
  background-color: var(--parvus-caption-background-color);
  color: var(--parvus-caption-color);
  padding-block-start: 0.5rem;
  text-align: start;
}
.parvus__copyright {
  background-color: var(--parvus-copyright-background-color);
  color: var(--parvus-copyright-color);
  inset-block-end: 0;
  inset-inline-end: 0;
  padding-inline: 0.25rem;
  position: absolute;
}
.parvus__loader {
  display: inline-block;
  block-size: 6.25rem;
  inset-inline-start: 50%;
  position: absolute;
  inset-block-start: 50%;
  transform: translate(-50%, -50%);
  inline-size: 6.25rem;
}
.parvus__loader::before {
  animation: spin 1s infinite linear;
  border-radius: 100%;
  border: 0.25rem solid var(--parvus-loader-background-color);
  border-block-start-color: var(--parvus-loader-color);
  content: "";
  inset: 0;
  position: absolute;
  z-index: 1;
}
.parvus__toolbar {
  align-items: center;
  display: flex;
  inset-block-start: 1rem;
  inset-inline: 1rem;
  justify-content: space-between;
  pointer-events: none;
  position: absolute;
  view-transition-name: toolbar;
  z-index: 8;
}
.parvus__toolbar > * {
  pointer-events: auto;
}
.parvus__controls {
  align-items: center;
  display: flex;
  gap: 0.5rem;
}
.parvus__btn {
  appearance: none;
  background-color: var(--parvus-btn-background-color);
  background-image: none;
  border-radius: 0;
  border: 0.0625rem solid transparent;
  color: var(--parvus-btn-color);
  cursor: pointer;
  display: flex;
  font: inherit;
  padding: 0.3125rem;
  position: relative;
  touch-action: manipulation;
  will-change: transform, opacity;
  z-index: 7;
}
.parvus__btn:hover, .parvus__btn:focus-visible {
  background-color: var(--parvus-btn-hover-background-color);
  color: var(--parvus-btn-hover-color);
}
.parvus__btn--previous {
  inset-inline-start: 0;
  position: absolute;
  inset-block-start: calc(50svh - 1rem);
  transform: translateY(-50%);
}
.parvus__btn--next {
  position: absolute;
  inset-inline-end: 0;
  inset-block-start: calc(50svh - 1rem);
  transform: translateY(-50%);
}
.parvus__btn svg {
  pointer-events: none;
}
.parvus__btn[aria-hidden=true] {
  display: none;
}
.parvus__btn[aria-disabled=true] {
  background-color: var(--parvus-btn-disabled-background-color);
  color: var(--parvus-btn-disabled-color);
}
.parvus__counter {
  position: relative;
  z-index: 7;
}
.parvus__counter[aria-hidden=true] {
  display: none;
}
@media screen and (prefers-reduced-motion: no-preference) {
  .parvus__overlay, .parvus__counter, .parvus__btn, .parvus__caption, .parvus__copyright {
    transition: transform var(--parvus-transition-duration) var(--parvus-transition-timing-function), opacity var(--parvus-transition-duration) var(--parvus-transition-timing-function);
    will-change: transform, opacity;
  }
  .parvus__copyright {
    transition-delay: var(--parvus-transition-duration);
  }
  .parvus--is-closing .parvus__copyright, .parvus--is-vertical-closing .parvus__copyright, .parvus--is-zooming .parvus__copyright {
    transition-delay: 0s;
    transition-duration: 0s;
  }
  .parvus--is-opening .parvus__overlay, .parvus--is-opening .parvus__counter, .parvus--is-opening .parvus__btn, .parvus--is-opening .parvus__caption, .parvus--is-opening .parvus__copyright, .parvus--is-closing .parvus__overlay, .parvus--is-closing .parvus__counter, .parvus--is-closing .parvus__btn, .parvus--is-closing .parvus__caption, .parvus--is-closing .parvus__copyright {
    opacity: 0;
  }
  .parvus--is-vertical-closing .parvus__counter, .parvus--is-vertical-closing .parvus__btn:not(.parvus__btn--previous, .parvus__btn--next), .parvus--is-zooming .parvus__counter, .parvus--is-zooming .parvus__btn:not(.parvus__btn--previous, .parvus__btn--next) {
    transform: translateY(-100%);
    opacity: 0;
  }
  .parvus--is-vertical-closing .parvus__btn--previous, .parvus--is-zooming .parvus__btn--previous {
    transform: translate(-100%, -50%);
    opacity: 0;
  }
  .parvus--is-vertical-closing .parvus__btn--next, .parvus--is-zooming .parvus__btn--next {
    transform: translate(100%, -50%);
    opacity: 0;
  }
  .parvus--is-vertical-closing .parvus__caption, .parvus--is-zooming .parvus__caption {
    transform: translateY(100%);
    opacity: 0;
  }
  .parvus--is-vertical-closing .parvus__copyright, .parvus--is-zooming .parvus__copyright {
    opacity: 0;
  }
}

@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

================================================
FILE: dist/js/parvus.esm.js
================================================
/**
 * Parvus
 *
 * @author Benjamin de Oostfrees
 * @version 3.1.0
 * @url https://github.com/deoostfrees/parvus
 *
 * MIT license
 */

const BROWSER_WINDOW = window;

/**
 * Get scrollbar width
 *
 * @return {Number} - The scrollbar width
 */
const getScrollbarWidth = () => {
  return BROWSER_WINDOW.innerWidth - document.documentElement.clientWidth;
};
const FOCUSABLE_ELEMENTS = ['a:not([inert]):not([tabindex^="-"])', 'button:not([inert]):not([tabindex^="-"]):not(:disabled)', '[tabindex]:not([inert]):not([tabindex^="-"])'];

/**
 * Get the focusable children of the given element
 *
 * @return {Array<Element>} - An array of focusable children
 */
const getFocusableChildren = targetEl => {
  return Array.from(targetEl.querySelectorAll(FOCUSABLE_ELEMENTS.join(', '))).filter(child => child.offsetParent !== null);
};

var en = {
  lightboxLabel: 'This is a dialog window that overlays the main content of the page. The modal displays the enlarged image. Pressing the Escape key will close the modal and bring you back to where you were on the page.',
  lightboxLoadingIndicatorLabel: 'Image loading',
  lightboxLoadingError: 'The requested image cannot be loaded.',
  controlsLabel: 'Controls',
  previousButtonLabel: 'Previous image',
  nextButtonLabel: 'Next image',
  closeButtonLabel: 'Close dialog window',
  sliderLabel: 'Images',
  slideLabel: 'Image'
};

/**
 * Default configuration options
 */
const DEFAULT_OPTIONS = {
  selector: '.lightbox',
  gallerySelector: null,
  zoomIndicator: true,
  captions: true,
  captionsSelector: 'self',
  captionsAttribute: 'data-caption',
  copyright: true,
  copyrightSelector: 'self',
  copyrightAttribute: 'data-copyright',
  docClose: true,
  swipeClose: true,
  simulateTouch: true,
  threshold: 50,
  hideScrollbar: true,
  lightboxIndicatorIcon: '<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>',
  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>',
  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>',
  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>',
  l10n: en
};

/**
 * Merge default options with user-provided options
 *
 * @param {Object} userOptions - User-provided options
 * @returns {Object} - Merged options object
 */
const mergeOptions = userOptions => {
  const MERGED_OPTIONS = {
    ...DEFAULT_OPTIONS,
    ...userOptions
  };
  if (userOptions && userOptions.l10n) {
    MERGED_OPTIONS.l10n = {
      ...DEFAULT_OPTIONS.l10n,
      ...userOptions.l10n
    };
  }
  return MERGED_OPTIONS;
};

/**
 * State management for Parvus
 *
 * Centralizes all mutable state variables
 */
class ParvusState {
  constructor() {
    // Group management
    this.GROUP_ATTRIBUTES = {
      triggerElements: [],
      slider: null,
      sliderElements: [],
      contentElements: []
    };
    this.GROUPS = {};
    this.groupIdCounter = 0;
    this.newGroup = null;
    this.activeGroup = null;
    this.currentIndex = 0;

    // Configuration
    this.config = {};

    // DOM elements
    this.lightbox = null;
    this.lightboxOverlay = null;
    this.lightboxOverlayOpacity = 1;
    this.toolbar = null;
    this.toolbarLeft = null;
    this.toolbarRight = null;
    this.controls = null;
    this.previousButton = null;
    this.nextButton = null;
    this.closeButton = null;
    this.counter = null;

    // Drag & interaction state
    this.drag = {};
    this.isDraggingX = false;
    this.isDraggingY = false;
    this.pointerDown = false;
    this.activePointers = new Map();

    // Zoom state
    this.currentScale = 1;
    this.isPinching = false;
    this.isTap = false;
    this.pinchStartDistance = 0;
    this.lastPointersId = null;

    // Offset & animation
    this.offset = null;
    this.offsetTmp = null;
    this.resizeTicking = false;
    this.isReducedMotion = true;
  }

  /**
   * Clear drag state
   */
  clearDrag() {
    this.drag = {
      startX: 0,
      endX: 0,
      startY: 0,
      endY: 0
    };
  }

  /**
   * Get the active group
   *
   * @returns {Object} The active group
   */
  getActiveGroup() {
    return this.GROUPS[this.activeGroup];
  }

  /**
   * Reset zoom state
   */
  resetZoomState() {
    this.isPinching = false;
    this.isTap = false;
    this.currentScale = 1;
    this.pinchStartDistance = 0;
    this.lastPointersId = '';
  }
}

/**
 * Event System Module
 *
 * Handles custom event dispatching and listeners
 */

/**
 * Dispatch a custom event
 *
 * @param {HTMLElement} lightbox - The lightbox element
 * @param {String} type - The type of the event to dispatch
 * @returns {void}
 */
const dispatchCustomEvent = (lightbox, type) => {
  const CUSTOM_EVENT = new CustomEvent(type, {
    cancelable: true
  });
  lightbox.dispatchEvent(CUSTOM_EVENT);
};

/**
 * Bind a specific event listener
 *
 * @param {HTMLElement} lightbox - The lightbox element
 * @param {String} eventName - The name of the event to bind
 * @param {Function} callback - The callback function
 * @returns {void}
 */
const on = (lightbox, eventName, callback) => {
  if (lightbox) {
    lightbox.addEventListener(eventName, callback);
  }
};

/**
 * Unbind a specific event listener
 *
 * @param {HTMLElement} lightbox - The lightbox element
 * @param {String} eventName - The name of the event to unbind
 * @param {Function} callback - The callback function
 * @returns {void}
 */
const off = (lightbox, eventName, callback) => {
  if (lightbox) {
    lightbox.removeEventListener(eventName, callback);
  }
};

/**
 * Navigation Module
 *
 * Handles slide navigation and transitions
 */

/**
 * Update offset
 *
 * @param {Object} state - The application state
 * @returns {void}
 */
const updateOffset = state => {
  state.activeGroup = state.activeGroup !== null ? state.activeGroup : state.newGroup;
  state.offset = -state.currentIndex * state.lightbox.offsetWidth;
  state.GROUPS[state.activeGroup].slider.style.transform = `translate3d(${state.offset}px, 0, 0)`;
  state.offsetTmp = state.offset;
};

/**
 * Load slide with the specified index
 *
 * @param {Object} state - The application state
 * @param {Number} index - The index of the slide to be loaded
 * @returns {void}
 */
const loadSlide = (state, index) => {
  state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'false');
};

/**
 * Leave slide
 *
 * @param {Object} state - The application state
 * @param {Number} index - The index of the slide to leave
 * @returns {void}
 */
const leaveSlide = (state, index) => {
  if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
    state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'true');
  }
};

/**
 * Preload slide with the specified index
 *
 * @param {Object} state - The application state
 * @param {Function} createSlide - Create slide function
 * @param {Function} createImage - Create image function
 * @param {Function} loadImage - Load image function
 * @param {Number} index - The index of the slide to be preloaded
 * @returns {void}
 */
const preload = (state, createSlide, createImage, loadImage, index) => {
  if (index < 0 || index >= state.GROUPS[state.activeGroup].triggerElements.length || state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
    return;
  }
  createSlide(state, index);
  createImage(state, state.GROUPS[state.activeGroup].triggerElements[index], index, () => {
    loadImage(state, index);
  });
};

/**
 * Utils Module
 *
 * Utility functions
 */

/**
 * Check prefers reduced motion
 *
 * @param {Object} state - The application state
 * @param {MediaQueryList} motionQuery - The media query list
 * @returns {void}
 */
const reducedMotionCheck = (state, motionQuery) => {
  if (motionQuery.matches) {
    state.isReducedMotion = true;
  } else {
    state.isReducedMotion = false;
  }
};

/**
 * Retrieves or creates a group identifier for the given element
 *
 * @param {Object} state - The application state
 * @param {HTMLElement} el - DOM element to get or assign a group to
 * @returns {string} The group identifier associated with the element
 */
const getGroup = (state, el) => {
  // Return existing group identifier if already assigned
  if (el.dataset.group) {
    return el.dataset.group;
  }

  // Generate new unique group identifier using counter
  const EL_GROUP = `default-${state.groupIdCounter++}`;

  // Assign the new group identifier to element's dataset
  el.dataset.group = EL_GROUP;
  return EL_GROUP;
};

/**
 * Plugin management for Parvus
 *
 * Provides a system for registering and managing plugins
 */

class PluginManager {
  constructor() {
    this.plugins = [];
    this.hooks = {};
    this.context = null;
    this.isInitialized = false;
  }

  /**
   * Register a plugin
   *
   * @param {Object} plugin - Plugin object with name and install function
   * @param {Object} options - Plugin-specific options
   */
  register(plugin, options = {}) {
    if (!plugin || typeof plugin.install !== 'function') {
      throw new Error('Plugin must have an install function');
    }
    if (!plugin.name) {
      throw new Error('Plugin must have a name');
    }

    // Check if plugin is already registered
    const existingPlugin = this.plugins.find(p => p.name === plugin.name);
    if (existingPlugin) {
      console.warn(`Plugin "${plugin.name}" is already registered`);
      return;
    }
    this.plugins.push({
      plugin,
      options
    });

    // If already initialized, install immediately
    if (this.isInitialized && this.context) {
      this.installPlugin(plugin, options);
    }
  }

  /**
   * Install a single plugin
   *
   * @param {Object} plugin - Plugin object
   * @param {Object} options - Plugin options
   */
  installPlugin(plugin, options) {
    try {
      plugin.install(this.context, options);

      // If lightbox already exists, execute afterInit hook for this plugin immediately
      if (this.context && this.context.state && this.context.state.lightbox) {
        this.executeHook('afterInit', {
          state: this.context.state
        });
      }
    } catch (error) {
      console.error(`Failed to install plugin "${plugin.name}":`, error);
    }
  }

  /**
   * Install all registered plugins
   *
   * @param {Object} context - Parvus instance context
   */
  install(context) {
    this.context = context;
    this.isInitialized = true;
    this.plugins.forEach(({
      plugin,
      options
    }) => {
      this.installPlugin(plugin, options);
    });
  }

  /**
   * Execute a hook
   *
   * @param {String} hookName - Name of the hook
   * @param {*} data - Data to pass to hook callbacks
   */
  executeHook(hookName, data) {
    const callbacks = this.hooks[hookName] || [];
    callbacks.forEach(callback => {
      try {
        callback(data);
      } catch (error) {
        console.error(`Error in hook "${hookName}":`, error);
      }
    });
  }

  /**
   * Register a hook callback
   *
   * @param {String} hookName - Name of the hook
   * @param {Function} callback - Callback function
   */
  addHook(hookName, callback) {
    if (!this.hooks[hookName]) {
      this.hooks[hookName] = [];
    }
    this.hooks[hookName].push(callback);
  }

  /**
   * Remove a hook callback
   *
   * @param {String} hookName - Name of the hook
   * @param {Function} callback - Callback function to remove
   */
  removeHook(hookName, callback) {
    if (!this.hooks[hookName]) return;
    this.hooks[hookName] = this.hooks[hookName].filter(cb => cb !== callback);
  }

  /**
   * Get all registered plugins
   *
   * @returns {Array} Array of plugin names
   */
  getPlugins() {
    return this.plugins.map(p => p.plugin.name);
  }
}

/**
 * UI Components Module
 *
 * Handles creation of lightbox, toolbar, slider and slides
 */

/**
 * Create the lightbox
 *
 * @param {Object} state - The application state
 * @returns {void}
 */
const createLightbox = state => {
  const {
    config
  } = state;

  // Use DocumentFragment to batch DOM operations
  const fragment = document.createDocumentFragment();

  // Create the lightbox container
  state.lightbox = document.createElement('dialog');
  state.lightbox.setAttribute('role', 'dialog');
  state.lightbox.setAttribute('aria-modal', 'true');
  state.lightbox.setAttribute('aria-label', config.l10n.lightboxLabel);
  state.lightbox.classList.add('parvus');

  // Create the lightbox overlay container
  state.lightboxOverlay = document.createElement('div');
  state.lightboxOverlay.classList.add('parvus__overlay');

  // Create the toolbar
  state.toolbar = document.createElement('div');
  state.toolbar.className = 'parvus__toolbar';

  // Create the toolbar items
  state.toolbarLeft = document.createElement('div');
  state.toolbarRight = document.createElement('div');

  // Create the controls
  state.controls = document.createElement('div');
  state.controls.className = 'parvus__controls';
  state.controls.setAttribute('role', 'group');
  state.controls.setAttribute('aria-label', config.l10n.controlsLabel);

  // Create the close button
  state.closeButton = document.createElement('button');
  state.closeButton.className = 'parvus__btn parvus__btn--close';
  state.closeButton.setAttribute('type', 'button');
  state.closeButton.setAttribute('aria-label', config.l10n.closeButtonLabel);
  state.closeButton.innerHTML = config.closeButtonIcon;

  // Create the previous button
  state.previousButton = document.createElement('button');
  state.previousButton.className = 'parvus__btn parvus__btn--previous';
  state.previousButton.setAttribute('type', 'button');
  state.previousButton.setAttribute('aria-label', config.l10n.previousButtonLabel);
  state.previousButton.innerHTML = config.previousButtonIcon;

  // Create the next button
  state.nextButton = document.createElement('button');
  state.nextButton.className = 'parvus__btn parvus__btn--next';
  state.nextButton.setAttribute('type', 'button');
  state.nextButton.setAttribute('aria-label', config.l10n.nextButtonLabel);
  state.nextButton.innerHTML = config.nextButtonIcon;

  // Create the counter
  state.counter = document.createElement('div');
  state.counter.className = 'parvus__counter';

  // Add the control buttons to the controls
  state.controls.append(state.closeButton, state.previousButton, state.nextButton);

  // Add the counter to the left toolbar item
  state.toolbarLeft.appendChild(state.counter);

  // Add the controls to the right toolbar item
  state.toolbarRight.appendChild(state.controls);

  // Add the toolbar items to the toolbar
  state.toolbar.append(state.toolbarLeft, state.toolbarRight);

  // Add the overlay and the toolbar to the lightbox
  state.lightbox.append(state.lightboxOverlay, state.toolbar);
  fragment.appendChild(state.lightbox);

  // Add to document body
  document.body.appendChild(fragment);
};

/**
 * Create a slider
 *
 * @param {Object} state - The application state
 * @returns {void}
 */
const createSlider = state => {
  const SLIDER = document.createElement('div');
  SLIDER.className = 'parvus__slider';

  // Update the slider reference in GROUPS
  state.GROUPS[state.activeGroup].slider = SLIDER;

  // Add the slider to the lightbox container
  state.lightbox.appendChild(SLIDER);
};

/**
 * Get next slide index
 *
 * @param {Object} state - The application state
 * @param {Number} currentIndex - Current slide index
 * @returns {number} Index of the next available slide or -1 if none found
 */
const getNextSlideIndex = (state, currentIndex) => {
  const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;
  const TOTAL_SLIDE_ELEMENTS = SLIDE_ELEMENTS.length;
  for (let i = currentIndex + 1; i < TOTAL_SLIDE_ELEMENTS; i++) {
    if (SLIDE_ELEMENTS[i] !== undefined) {
      return i;
    }
  }
  return -1;
};

/**
 * Get previous slide index
 *
 * @param {Object} state - The application state
 * @param {number} currentIndex - Current slide index
 * @returns {number} Index of the previous available slide or -1 if none found
 */
const getPreviousSlideIndex = (state, currentIndex) => {
  const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;
  for (let i = currentIndex - 1; i >= 0; i--) {
    if (SLIDE_ELEMENTS[i] !== undefined) {
      return i;
    }
  }
  return -1;
};

/**
 * Create a slide
 *
 * @param {Object} state - The application state
 * @param {Number} index - The index of the slide
 * @returns {void}
 */
const createSlide = (state, index) => {
  if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
    return;
  }
  const FRAGMENT = document.createDocumentFragment();
  const SLIDE_ELEMENT = document.createElement('div');
  const SLIDE_ELEMENT_CONTENT = document.createElement('div');
  const GROUP = state.GROUPS[state.activeGroup];
  const TOTAL_TRIGGER_ELEMENTS = GROUP.triggerElements.length;
  SLIDE_ELEMENT.className = 'parvus__slide';
  SLIDE_ELEMENT.style.cssText = `
    position: absolute;
    left: ${index * 100}%;
  `;
  SLIDE_ELEMENT.setAttribute('aria-hidden', 'true');

  // Add accessibility attributes if gallery has multiple slides
  if (TOTAL_TRIGGER_ELEMENTS > 1) {
    SLIDE_ELEMENT.setAttribute('role', 'group');
    SLIDE_ELEMENT.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`);
  }
  SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT);
  FRAGMENT.appendChild(SLIDE_ELEMENT);
  GROUP.sliderElements[index] = SLIDE_ELEMENT;

  // Insert the slide element based on index position
  if (index >= state.currentIndex) {
    // Insert the slide element after the current slide
    const NEXT_SLIDE_INDEX = getNextSlideIndex(state, index);
    if (NEXT_SLIDE_INDEX !== -1) {
      GROUP.sliderElements[NEXT_SLIDE_INDEX].before(SLIDE_ELEMENT);
    } else {
      GROUP.slider.appendChild(SLIDE_ELEMENT);
    }
  } else {
    // Insert the slide element before the current slide
    const PREVIOUS_SLIDE_INDEX = getPreviousSlideIndex(state, index);
    if (PREVIOUS_SLIDE_INDEX !== -1) {
      GROUP.sliderElements[PREVIOUS_SLIDE_INDEX].after(SLIDE_ELEMENT);
    } else {
      GROUP.slider.prepend(SLIDE_ELEMENT);
    }
  }
};

/**
 * Update counter
 *
 * @param {Object} state - The application state
 * @returns {void}
 */
const updateCounter = state => {
  state.counter.textContent = `${state.currentIndex + 1}/${state.GROUPS[state.activeGroup].triggerElements.length}`;
};

/**
 * Update Attributes
 *
 * @param {Object} state - The application state
 * @returns {void}
 */
const updateAttributes = state => {
  const TRIGGER_ELEMENTS = state.GROUPS[state.activeGroup].triggerElements;
  const TOTAL_TRIGGER_ELEMENTS = TRIGGER_ELEMENTS.length;
  const SLIDER = state.GROUPS[state.activeGroup].slider;
  const SLIDER_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;
  const IS_DRAGGABLE = SLIDER.classList.contains('parvus__slider--is-draggable');

  // Add draggable class if necessary
  if (state.config.simulateTouch && state.config.swipeClose && !IS_DRAGGABLE || state.config.simulateTouch && TOTAL_TRIGGER_ELEMENTS > 1 && !IS_DRAGGABLE) {
    SLIDER.classList.add('parvus__slider--is-draggable');
  } else {
    SLIDER.classList.remove('parvus__slider--is-draggable');
  }

  // Add extra output for screen reader if there is more than one slide
  if (TOTAL_TRIGGER_ELEMENTS > 1) {
    SLIDER.setAttribute('role', 'region');
    SLIDER.setAttribute('aria-roledescription', 'carousel');
    SLIDER.setAttribute('aria-label', state.config.l10n.sliderLabel);
    SLIDER_ELEMENTS.forEach((sliderElement, index) => {
      sliderElement.setAttribute('role', 'group');
      sliderElement.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`);
    });
  } else {
    SLIDER.removeAttribute('role');
    SLIDER.removeAttribute('aria-roledescription');
    SLIDER.removeAttribute('aria-label');
    SLIDER_ELEMENTS.forEach(sliderElement => {
      sliderElement.removeAttribute('role');
      sliderElement.removeAttribute('aria-label');
    });
  }

  // Show or hide buttons
  if (TOTAL_TRIGGER_ELEMENTS === 1) {
    state.counter.setAttribute('aria-hidden', 'true');
    state.previousButton.setAttribute('aria-hidden', 'true');
    state.nextButton.setAttribute('aria-hidden', 'true');
  } else {
    state.counter.removeAttribute('aria-hidden');
    state.previousButton.removeAttribute('aria-hidden');
    state.nextButton.removeAttribute('aria-hidden');
  }
};

/**
 * Update slider navigation status
 *
 * @param {Object} state - The application state
 * @returns {void}
 */
const updateSliderNavigationStatus = state => {
  const {
    triggerElements
  } = state.GROUPS[state.activeGroup];
  const TOTAL_TRIGGER_ELEMENTS = triggerElements.length;
  if (TOTAL_TRIGGER_ELEMENTS <= 1) {
    return;
  }

  // Determine navigation state
  const FIRST_SLIDE = state.currentIndex === 0;
  const LAST_SLIDE = state.currentIndex === TOTAL_TRIGGER_ELEMENTS - 1;

  // Set previous button state
  const PREV_DISABLED = FIRST_SLIDE ? 'true' : null;
  if (state.previousButton.getAttribute('aria-disabled') === 'true' !== !!PREV_DISABLED) {
    PREV_DISABLED ? state.previousButton.setAttribute('aria-disabled', 'true') : state.previousButton.removeAttribute('aria-disabled');
  }

  // Set next button state
  const NEXT_DISABLED = LAST_SLIDE ? 'true' : null;
  if (state.nextButton.getAttribute('aria-disabled') === 'true' !== !!NEXT_DISABLED) {
    NEXT_DISABLED ? state.nextButton.setAttribute('aria-disabled', 'true') : state.nextButton.removeAttribute('aria-disabled');
  }
};

/**
 * Add zoom indicator to element
 *
 * @param {HTMLElement} el - The element to add the zoom indicator to
 * @param {Object} config - Options object
 */
const addZoomIndicator = (el, config) => {
  if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') === null) {
    const LIGHTBOX_INDICATOR_ICON = document.createElement('div');
    LIGHTBOX_INDICATOR_ICON.className = 'parvus-zoom__indicator';
    LIGHTBOX_INDICATOR_ICON.innerHTML = config.lightboxIndicatorIcon;
    el.appendChild(LIGHTBOX_INDICATOR_ICON);
  }
};

/**
 * Remove zoom indicator for element
 *
 * @param {HTMLElement} el - The element to remove the zoom indicator to
 */
const removeZoomIndicator = el => {
  if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') !== null) {
    const LIGHTBOX_INDICATOR_ICON = el.querySelector('.parvus-zoom__indicator');
    el.removeChild(LIGHTBOX_INDICATOR_ICON);
  }
};

/**
 * Keyboard Event Handler Module
 *
 * Handles all keyboard interactions
 */


/**
 * Create keyboard event handler
 *
 * @param {Object} state - The application state
 * @param {Object} actions - Actions object with navigation functions
 * @returns {Function} Keyboard event handler
 */
const createKeydownHandler = (state, actions) => {
  return event => {
    const FOCUSABLE_CHILDREN = getFocusableChildren(state.lightbox);
    const FOCUSED_ITEM_INDEX = FOCUSABLE_CHILDREN.indexOf(document.activeElement);
    const lastIndex = FOCUSABLE_CHILDREN.length - 1;
    switch (event.code) {
      case 'Tab':
        {
          // Use the TAB key to navigate backwards and forwards
          if (event.shiftKey) {
            // Navigate backwards
            if (FOCUSED_ITEM_INDEX === 0) {
              FOCUSABLE_CHILDREN[lastIndex].focus();
              event.preventDefault();
            }
          } else {
            // Navigate forwards
            if (FOCUSED_ITEM_INDEX === lastIndex) {
              FOCUSABLE_CHILDREN[0].focus();
              event.preventDefault();
            }
          }
          break;
        }
      case 'Escape':
        {
          // Close Parvus when the ESC key is pressed
          actions.close();
          event.preventDefault();
          break;
        }
      case 'ArrowLeft':
        {
          // Show the previous slide when the PREV key is pressed
          actions.previous();
          event.preventDefault();
          break;
        }
      case 'ArrowRight':
        {
          // Show the next slide when the NEXT key is pressed
          actions.next();
          event.preventDefault();
          break;
        }
    }
  };
};

/**
 * Pointer Event Handler Module
 *
 * Handles all pointer interactions (mouse, touch, pen)
 */

/**
 * Create pointerdown event handler
 *
 * @param {Object} state - The application state
 * @returns {Function} Pointerdown event handler
 */
const createPointerdownHandler = state => {
  return event => {
    event.preventDefault();
    event.stopPropagation();
    state.isDraggingX = false;
    state.isDraggingY = false;
    state.pointerDown = true;
    state.activePointers.set(event.pointerId, event);
    state.drag.startX = event.pageX;
    state.drag.startY = event.pageY;
    state.drag.endX = event.pageX;
    state.drag.endY = event.pageY;
    const {
      slider
    } = state.GROUPS[state.activeGroup];
    slider.classList.add('parvus__slider--is-dragging');
    slider.style.willChange = 'transform';
    state.isTap = state.activePointers.size === 1;
    if (state.config.swipeClose) {
      state.lightboxOverlayOpacity = getComputedStyle(state.lightboxOverlay).opacity;
    }
  };
};

/**
 * Create pointermove event handler
 *
 * @param {Object} state - The application state
 * @param {Function} pinchZoom - Pinch zoom function
 * @param {Function} doSwipe - Swipe function
 * @returns {Function} Pointermove event handler
 */
const createPointermoveHandler = (state, pinchZoom, doSwipe) => {
  return event => {
    event.preventDefault();
    if (!state.pointerDown) {
      return;
    }
    const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex];

    // Update pointer position
    state.activePointers.set(event.pointerId, event);

    // Zoom
    if (CURRENT_IMAGE && CURRENT_IMAGE.tagName === 'IMG') {
      if (state.activePointers.size === 2) {
        pinchZoom(CURRENT_IMAGE);
        return;
      }
      if (state.currentScale > 1) {
        return;
      }
    }
    state.drag.endX = event.pageX;
    state.drag.endY = event.pageY;
    doSwipe();
  };
};

/**
 * Create pointerup event handler
 *
 * @param {Object} state - The application state
 * @param {Function} resetZoom - Reset zoom function
 * @param {Function} updateAfterDrag - Update after drag function
 * @returns {Function} Pointerup event handler
 */
const createPointerupHandler = (state, resetZoom, updateAfterDrag) => {
  return event => {
    event.stopPropagation();
    const {
      slider
    } = state.GROUPS[state.activeGroup];
    state.activePointers.delete(event.pointerId);
    if (state.activePointers.size > 0) {
      return;
    }
    state.pointerDown = false;
    const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex];

    // Reset zoom state by one tap
    const MOVEMENT_X = Math.abs(state.drag.endX - state.drag.startX);
    const MOVEMENT_Y = Math.abs(state.drag.endY - state.drag.startY);
    const IS_TAP = MOVEMENT_X < 8 && MOVEMENT_Y < 8 && !state.isDraggingX && !state.isDraggingY && state.isTap;
    slider.classList.remove('parvus__slider--is-dragging');
    slider.style.willChange = '';
    if (state.currentScale > 1) {
      if (IS_TAP) {
        resetZoom(CURRENT_IMAGE);
      } else {
        CURRENT_IMAGE.style.transform = `
          scale(${state.currentScale})
        `;
      }
    } else {
      if (state.isPinching) {
        resetZoom(CURRENT_IMAGE);
      }
      if (state.drag.endX || state.drag.endY) {
        updateAfterDrag();
      }
    }
    state.clearDrag();
  };
};

/**
 * Create click event handler
 *
 * @param {Object} state - The application state
 * @param {Object} actions - Actions object with navigation functions
 * @returns {Function} Click event handler
 */
const createClickHandler = (state, actions) => {
  return event => {
    const {
      target
    } = event;
    if (target === state.previousButton) {
      actions.previous();
    } else if (target === state.nextButton) {
      actions.next();
    } else if (target === state.closeButton || state.config.docClose && !state.isDraggingY && !state.isDraggingX && target.classList.contains('parvus__slide')) {
      actions.close();
    }
    event.stopPropagation();
  };
};

/**
 * Gesture Handler Module
 *
 * Handles gestures like pinch-to-zoom and swipe
 */

/**
 * Reset image zoom
 *
 * @param {Object} state - The application state
 * @param {HTMLImageElement} currentImg - The image
 * @returns {void}
 */
const resetZoom = (state, currentImg) => {
  currentImg.style.transition = 'transform 0.3s ease';
  currentImg.style.transform = '';
  setTimeout(() => {
    currentImg.style.transition = '';
    currentImg.style.transformOrigin = '';
  }, 300);
  state.resetZoomState();
  state.lightbox.classList.remove('parvus--is-zooming');
};

/**
 * Pinch zoom gesture
 *
 * @param {Object} state - The application state
 * @param {HTMLImageElement} currentImg - The image to zoom
 * @returns {void}
 */
const pinchZoom = (state, currentImg) => {
  // Determine current finger positions
  const POINTS = Array.from(state.activePointers.values());

  // Calculate current distance between fingers
  const CURRENT_DISTANCE = Math.hypot(POINTS[1].clientX - POINTS[0].clientX, POINTS[1].clientY - POINTS[0].clientY);

  // Calculate the midpoint between the two points
  const MIDPOINT_X = (POINTS[0].clientX + POINTS[1].clientX) / 2;
  const MIDPOINT_Y = (POINTS[0].clientY + POINTS[1].clientY) / 2;

  // Convert midpoint to relative position within the image
  const IMG_RECT = currentImg.getBoundingClientRect();
  const RELATIVE_X = (MIDPOINT_X - IMG_RECT.left) / IMG_RECT.width;
  const RELATIVE_Y = (MIDPOINT_Y - IMG_RECT.top) / IMG_RECT.height;

  // When pinch gesture is about to start or the finger IDs have changed
  // Use a unique ID based on the pointer IDs to recognize changes
  const CURRENT_POINTERS_ID = POINTS.map(p => p.pointerId).sort().join('-');
  const IS_NEW_POINTER_COMBINATION = state.lastPointersId !== CURRENT_POINTERS_ID;
  if (!state.isPinching || IS_NEW_POINTER_COMBINATION) {
    state.isPinching = true;
    state.lastPointersId = CURRENT_POINTERS_ID;

    // Save the start distance and current scaling as a basis
    state.pinchStartDistance = CURRENT_DISTANCE / state.currentScale;

    // Store initial pinch position for this gesture
    if (!currentImg.style.transformOrigin && state.currentScale === 1 || state.currentScale === 1 && IS_NEW_POINTER_COMBINATION) {
      // Set the transform origin to the pinch midpoint
      currentImg.style.transformOrigin = `${RELATIVE_X * 100}% ${RELATIVE_Y * 100}%`;
    }
    state.lightbox.classList.add('parvus--is-zooming');
  }

  // Calculate scaling factor based on distance change
  const SCALE_FACTOR = CURRENT_DISTANCE / state.pinchStartDistance;

  // Limit scaling to 1 - 3
  state.currentScale = Math.min(Math.max(1, SCALE_FACTOR), 3);
  currentImg.style.willChange = 'transform';
  currentImg.style.transform = `scale(${state.currentScale})`;
};

/**
 * Determine the swipe direction (horizontal or vertical)
 *
 * @param {Object} state - The application state
 * @returns {void}
 */
const doSwipe = state => {
  const MOVEMENT_THRESHOLD = 1.5;
  const MAX_OPACITY_DISTANCE = 100;
  const DIRECTION_BIAS = 1.15;
  const {
    startX,
    endX,
    startY,
    endY
  } = state.drag;
  const MOVEMENT_X = startX - endX;
  const MOVEMENT_Y = endY - startY;
  const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X);
  const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y);
  const GROUP = state.GROUPS[state.activeGroup];
  const SLIDER = GROUP.slider;
  const TOTAL_SLIDES = GROUP.triggerElements.length;
  const handleHorizontalSwipe = (movementX, distance) => {
    const IS_FIRST_SLIDE = state.currentIndex === 0;
    const IS_LAST_SLIDE = state.currentIndex === TOTAL_SLIDES - 1;
    const IS_LEFT_SWIPE = movementX > 0;
    const IS_RIGHT_SWIPE = movementX < 0;
    if (IS_FIRST_SLIDE && IS_RIGHT_SWIPE || IS_LAST_SLIDE && IS_LEFT_SWIPE) {
      const DAMPING_FACTOR = 1 / (1 + Math.pow(distance / 100, 0.15));
      const REDUCED_MOVEMENT = movementX * DAMPING_FACTOR;
      SLIDER.style.transform = `
        translate3d(${state.offsetTmp - Math.round(REDUCED_MOVEMENT)}px, 0, 0)
      `;
    } else {
      SLIDER.style.transform = `
        translate3d(${state.offsetTmp - Math.round(movementX)}px, 0, 0)
      `;
    }
  };
  const handleVerticalSwipe = (movementY, distance) => {
    if (!state.isReducedMotion && distance <= 100) {
      const NEW_OVERLAY_OPACITY = Math.max(0, state.lightboxOverlayOpacity - distance / MAX_OPACITY_DISTANCE);
      state.lightboxOverlay.style.opacity = NEW_OVERLAY_OPACITY;
    }
    state.lightbox.classList.add('parvus--is-vertical-closing');
    SLIDER.style.transform = `
      translate3d(${state.offsetTmp}px, ${Math.round(movementY)}px, 0)
    `;
  };
  if (state.isDraggingX || state.isDraggingY) {
    if (state.isDraggingX) {
      handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE);
    } else if (state.isDraggingY) {
      handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE);
    }
    return;
  }

  // Direction detection based on the relative ratio of movements
  if (MOVEMENT_X_DISTANCE > MOVEMENT_THRESHOLD || MOVEMENT_Y_DISTANCE > MOVEMENT_THRESHOLD) {
    // Horizontal swipe if X-movement is stronger than Y-movement * DIRECTION_BIAS
    if (MOVEMENT_X_DISTANCE > MOVEMENT_Y_DISTANCE * DIRECTION_BIAS && TOTAL_SLIDES > 1) {
      state.isDraggingX = true;
      state.isDraggingY = false;
      handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE);
    } else if (MOVEMENT_Y_DISTANCE > MOVEMENT_X_DISTANCE * DIRECTION_BIAS && state.config.swipeClose) {
      // Vertical swipe if Y-movement is stronger than X-movement * DIRECTION_BIAS
      state.isDraggingX = false;
      state.isDraggingY = true;
      handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE);
    }
  }
};

/**
 * Recalculate drag/swipe event after pointerup
 *
 * @param {Object} state - The application state
 * @param {Object} actions - Navigation actions
 * @returns {void}
 */
const updateAfterDrag = (state, actions) => {
  const {
    startX,
    startY,
    endX,
    endY
  } = state.drag;
  const MOVEMENT_X = endX - startX;
  const MOVEMENT_Y = endY - startY;
  const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X);
  const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y);
  const {
    triggerElements
  } = state.GROUPS[state.activeGroup];
  const TOTAL_TRIGGER_ELEMENTS = triggerElements.length;
  if (state.isDraggingX) {
    const IS_RIGHT_SWIPE = MOVEMENT_X > 0;
    if (MOVEMENT_X_DISTANCE >= state.config.threshold) {
      if (IS_RIGHT_SWIPE && state.currentIndex > 0) {
        actions.previous();
      } else if (!IS_RIGHT_SWIPE && state.currentIndex < TOTAL_TRIGGER_ELEMENTS - 1) {
        actions.next();
      }
    }
    actions.updateOffset();
  } else if (state.isDraggingY) {
    if (MOVEMENT_Y_DISTANCE >= state.config.threshold && state.config.swipeClose) {
      actions.close();
    } else {
      state.lightbox.classList.remove('parvus--is-vertical-closing');
      actions.updateOffset();
    }
    state.lightboxOverlay.style.opacity = '';
  } else {
    actions.updateOffset();
  }
};

/**
 * Image Handler Module
 *
 * Handles image loading, captions, and dimensions
 */

/**
 * Add caption to the container element
 *
 * @param {Object} config - Configuration object
 * @param {HTMLElement} containerEl - The container element to which the caption will be added
 * @param {HTMLElement} imageEl - The image the caption is linked to
 * @param {HTMLElement} el - The trigger element associated with the caption
 * @param {Number} index - The index of the caption
 * @returns {void}
 */
const addCaption = (config, containerEl, imageEl, el, index) => {
  const getCaptionData = triggerEl => {
    const {
      captionsAttribute,
      captionsSelector,
      captionsIdAttribute = 'data-caption-id'
    } = config;

    // Check for an ID reference on the trigger element
    // This allows the caption to be anywhere on the page
    const CAPTION_ID = triggerEl.getAttribute(captionsIdAttribute);
    if (CAPTION_ID) {
      const CAPTION_EL = document.getElementById(CAPTION_ID);
      if (CAPTION_EL) {
        return CAPTION_EL.innerHTML;
      }
    }

    // Check for a direct caption attribute on the trigger element
    const DIRECT_CAPTION = triggerEl.getAttribute(captionsAttribute);
    if (DIRECT_CAPTION) {
      return DIRECT_CAPTION;
    }

    // Query for a selector inside the trigger element
    if (captionsSelector !== 'self') {
      const CAPTION_EL = triggerEl.querySelector(captionsSelector);
      if (CAPTION_EL) {
        // Prefer a direct attribute on the found element, otherwise use its content
        return CAPTION_EL.getAttribute(captionsAttribute) || CAPTION_EL.innerHTML;
      }
    }
    return null;
  };
  const CAPTION_DATA = getCaptionData(el);
  if (CAPTION_DATA) {
    const CAPTION_CONTAINER = document.createElement('div');
    const CAPTION_ID = `parvus__caption-${index}`;
    CAPTION_CONTAINER.className = 'parvus__caption';
    CAPTION_CONTAINER.id = CAPTION_ID;
    CAPTION_CONTAINER.innerHTML = `<p>${CAPTION_DATA}</p>`;
    containerEl.appendChild(CAPTION_CONTAINER);
    imageEl.setAttribute('aria-describedby', CAPTION_ID);
  }
};

/**
 * Add copyright to the image container element
 *
 * @param {Object} config - Configuration object
 * @param {HTMLElement} imageContainer - The image container element (parvus__content) to which the copyright will be added
 * @param {HTMLElement} imageEl - The image the copyright is linked to
 * @param {HTMLElement} el - The trigger element associated with the copyright
 * @param {Number} index - The index of the copyright
 * @returns {void}
 */
const addCopyright = (config, imageContainer, imageEl, el, index) => {
  const getCopyrightData = triggerEl => {
    const {
      copyrightAttribute,
      copyrightSelector,
      copyrightIdAttribute = 'data-copyright-id'
    } = config;

    // Check for an ID reference on the trigger element
    // This allows the copyright to be anywhere on the page
    const COPYRIGHT_ID = triggerEl.getAttribute(copyrightIdAttribute);
    if (COPYRIGHT_ID) {
      const COPYRIGHT_EL = document.getElementById(COPYRIGHT_ID);
      if (COPYRIGHT_EL) {
        return COPYRIGHT_EL.innerHTML;
      }
    }

    // Check for a direct copyright attribute on the trigger element
    const DIRECT_COPYRIGHT = triggerEl.getAttribute(copyrightAttribute);
    if (DIRECT_COPYRIGHT) {
      return DIRECT_COPYRIGHT;
    }

    // Query for a selector inside the trigger element
    if (copyrightSelector !== 'self') {
      const COPYRIGHT_EL = triggerEl.querySelector(copyrightSelector);
      if (COPYRIGHT_EL) {
        // Prefer a direct attribute on the found element, otherwise use its content
        return COPYRIGHT_EL.getAttribute(copyrightAttribute) || COPYRIGHT_EL.innerHTML;
      }
    }
    return null;
  };
  const COPYRIGHT_DATA = getCopyrightData(el);
  if (COPYRIGHT_DATA) {
    const COPYRIGHT_CONTAINER = document.createElement('div');
    const COPYRIGHT_ID = `parvus__copyright-${index}`;
    COPYRIGHT_CONTAINER.className = 'parvus__copyright';
    COPYRIGHT_CONTAINER.id = COPYRIGHT_ID;
    COPYRIGHT_CONTAINER.innerHTML = `<small>${COPYRIGHT_DATA}</small>`;
    imageContainer.appendChild(COPYRIGHT_CONTAINER);

    // If image already has aria-describedby (from caption), append copyright ID
    const existingAriaDescribedby = imageEl.getAttribute('aria-describedby');
    if (existingAriaDescribedby) {
      imageEl.setAttribute('aria-describedby', `${existingAriaDescribedby} ${COPYRIGHT_ID}`);
    } else {
      imageEl.setAttribute('aria-describedby', COPYRIGHT_ID);
    }
  }
};

/**
 * Create image
 *
 * @param {Object} state - The application state
 * @param {HTMLElement} el - The trigger element
 * @param {Number} index - The index
 * @param {Function} callback - Callback function
 * @returns {void}
 */
const createImage = (state, el, index, callback) => {
  const {
    contentElements,
    sliderElements
  } = state.GROUPS[state.activeGroup];
  if (contentElements[index] !== undefined) {
    if (callback && typeof callback === 'function') {
      callback();
    }
    return;
  }
  const CONTENT_CONTAINER_EL = sliderElements[index].querySelector('div');
  const IMAGE = new Image();
  const IMAGE_CONTAINER = document.createElement('div');
  const THUMBNAIL = el.querySelector('img');
  const LOADING_INDICATOR = document.createElement('div');
  IMAGE_CONTAINER.className = 'parvus__content';

  // Create loading indicator
  LOADING_INDICATOR.className = 'parvus__loader';
  LOADING_INDICATOR.setAttribute('role', 'progressbar');
  LOADING_INDICATOR.setAttribute('aria-label', state.config.l10n.lightboxLoadingIndicatorLabel);

  // Add loading indicator to content container
  CONTENT_CONTAINER_EL.appendChild(LOADING_INDICATOR);
  const checkImagePromise = new Promise((resolve, reject) => {
    IMAGE.onload = () => resolve(IMAGE);
    IMAGE.onerror = error => reject(error);
  });
  checkImagePromise.then(loadedImage => {
    loadedImage.style.opacity = 0;
    IMAGE_CONTAINER.appendChild(loadedImage);

    // Add copyright if available (inside IMAGE_CONTAINER)
    if (state.config.copyright) {
      addCopyright(state.config, IMAGE_CONTAINER, IMAGE, el, index);
    }
    CONTENT_CONTAINER_EL.appendChild(IMAGE_CONTAINER);

    // Add caption if available
    if (state.config.captions) {
      addCaption(state.config, CONTENT_CONTAINER_EL, IMAGE, el, index);
    }
    contentElements[index] = loadedImage;

    // Set image width and height
    loadedImage.setAttribute('width', loadedImage.naturalWidth);
    loadedImage.setAttribute('height', loadedImage.naturalHeight);

    // Set image dimension
    setImageDimension(sliderElements[index], loadedImage);
  }).catch(() => {
    const ERROR_CONTAINER = document.createElement('div');
    ERROR_CONTAINER.classList.add('parvus__content');
    ERROR_CONTAINER.classList.add('parvus__content--error');
    ERROR_CONTAINER.textContent = state.config.l10n.lightboxLoadingError;
    CONTENT_CONTAINER_EL.appendChild(ERROR_CONTAINER);
    contentElements[index] = ERROR_CONTAINER;
  }).finally(() => {
    CONTENT_CONTAINER_EL.removeChild(LOADING_INDICATOR);
    if (callback && typeof callback === 'function') {
      callback();
    }
  });

  // Add `sizes` attribute
  if (el.hasAttribute('data-sizes') && el.getAttribute('data-sizes') !== '') {
    IMAGE.setAttribute('sizes', el.getAttribute('data-sizes'));
  }

  // Add `srcset` attribute
  if (el.hasAttribute('data-srcset') && el.getAttribute('data-srcset') !== '') {
    IMAGE.setAttribute('srcset', el.getAttribute('data-srcset'));
  }

  // Add `src` attribute
  if (el.tagName === 'A') {
    IMAGE.setAttribute('src', el.href);
  } else {
    IMAGE.setAttribute('src', el.getAttribute('data-target'));
  }

  // `alt` attribute
  if (THUMBNAIL && THUMBNAIL.hasAttribute('alt') && THUMBNAIL.getAttribute('alt') !== '') {
    IMAGE.alt = THUMBNAIL.alt;
  } else if (el.hasAttribute('data-alt') && el.getAttribute('data-alt') !== '') {
    IMAGE.alt = el.getAttribute('data-alt');
  } else {
    IMAGE.alt = '';
  }
};

/**
 * Load Image
 *
 * @param {Object} state - The application state
 * @param {Number} index - The index of the image to load
 * @param {Boolean} animate - Whether to animate the image
 * @returns {void}
 */
const loadImage = (state, index, animate) => {
  const IMAGE = state.GROUPS[state.activeGroup].contentElements[index];
  if (IMAGE && IMAGE.tagName === 'IMG') {
    const THUMBNAIL = state.GROUPS[state.activeGroup].triggerElements[index];
    if (animate && document.startViewTransition) {
      THUMBNAIL.style.viewTransitionName = 'lightboximage';
      const transition = document.startViewTransition(() => {
        IMAGE.style.opacity = '';
        THUMBNAIL.style.viewTransitionName = null;
        IMAGE.style.viewTransitionName = 'lightboximage';
      });
      transition.finished.finally(() => {
        IMAGE.style.viewTransitionName = null;
      });
    } else {
      IMAGE.style.opacity = '';
    }
  } else {
    IMAGE.style.opacity = '';
  }
};

/**
 * Set image dimension
 *
 * @param {HTMLElement} slideEl - The slide element
 * @param {HTMLElement} contentEl - The content element
 * @returns {void}
 */
const setImageDimension = (slideEl, contentEl) => {
  if (contentEl.tagName !== 'IMG') {
    return;
  }
  const SRC_HEIGHT = contentEl.getAttribute('height');
  const SRC_WIDTH = contentEl.getAttribute('width');
  if (!SRC_HEIGHT || !SRC_WIDTH) {
    return;
  }
  const SLIDE_EL_STYLES = getComputedStyle(slideEl);
  const HORIZONTAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingLeft) + parseFloat(SLIDE_EL_STYLES.paddingRight);
  const VERTICAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingTop) + parseFloat(SLIDE_EL_STYLES.paddingBottom);
  const CAPTION_EL = slideEl.querySelector('.parvus__caption');
  const CAPTION_HEIGHT = CAPTION_EL ? CAPTION_EL.getBoundingClientRect().height : 0;
  const MAX_WIDTH = slideEl.offsetWidth - HORIZONTAL_PADDING;
  const MAX_HEIGHT = slideEl.offsetHeight - VERTICAL_PADDING - CAPTION_HEIGHT;
  const RATIO = Math.min(MAX_WIDTH / SRC_WIDTH || 0, MAX_HEIGHT / SRC_HEIGHT || 0);
  const NEW_WIDTH = SRC_WIDTH * RATIO;
  const NEW_HEIGHT = SRC_HEIGHT * RATIO;
  const USE_ORIGINAL_SIZE = SRC_WIDTH <= MAX_WIDTH && SRC_HEIGHT <= MAX_HEIGHT;
  contentEl.style.width = USE_ORIGINAL_SIZE ? '' : `${NEW_WIDTH}px`;
  contentEl.style.height = USE_ORIGINAL_SIZE ? '' : `${NEW_HEIGHT}px`;
};

/**
 * Create resize handler
 *
 * @param {Object} state - The application state
 * @param {Function} updateOffset - Update offset function
 * @returns {Function} Resize event handler
 */
const createResizeHandler = (state, updateOffset) => {
  return () => {
    if (!state.resizeTicking) {
      state.resizeTicking = true;
      window.requestAnimationFrame(() => {
        state.GROUPS[state.activeGroup].sliderElements.forEach((slide, index) => {
          setImageDimension(slide, state.GROUPS[state.activeGroup].contentElements[index]);
        });
        updateOffset();
        state.resizeTicking = false;
      });
    }
  };
};

// Helper modules

/**
 * Parvus Lightbox
 *
 * @param {Object} userOptions - User configuration options
 * @returns {Object} Parvus instance
 */
function Parvus(userOptions) {
  const BROWSER_WINDOW = window;
  const STATE = new ParvusState();
  const MOTIONQUERY = BROWSER_WINDOW.matchMedia('(prefers-reduced-motion)');
  const PLUGIN_MANAGER = new PluginManager();

  // Event handlers will be created after actions are defined
  let keydownHandler, clickHandler, pointerdownHandler, pointermoveHandler, pointerupHandler, resizeHandler;

  /**
   * Click event handler to trigger Parvus
   *
   * @param {Event} event - The click event object
   */
  const triggerParvus = function triggerParvus(event) {
    event.preventDefault();
    open(this);
  };

  /**
   * Add an element
   *
   * @param {HTMLElement} el - The element to be added
   */
  const add = el => {
    // Check element type and attributes
    const IS_VALID_LINK = el.tagName === 'A' && el.hasAttribute('href');
    const IS_VALID_BUTTON = el.tagName === 'BUTTON' && el.hasAttribute('data-target');
    if (!IS_VALID_LINK && !IS_VALID_BUTTON) {
      throw new Error('Use a link with the \'href\' attribute or a button with the \'data-target\' attribute. Both attributes must contain a path to the image file.');
    }

    // Check if the lightbox already exists
    if (!STATE.lightbox) {
      createLightbox(STATE);

      // Execute afterInit hook when lightbox is first created
      PLUGIN_MANAGER.executeHook('afterInit', {
        state: STATE
      });
    }
    STATE.newGroup = getGroup(STATE, el);
    if (!STATE.GROUPS[STATE.newGroup]) {
      STATE.GROUPS[STATE.newGroup] = structuredClone(STATE.GROUP_ATTRIBUTES);
    }
    if (STATE.GROUPS[STATE.newGroup].triggerElements.includes(el)) {
      throw new Error('Ups, element already added.');
    }
    STATE.GROUPS[STATE.newGroup].triggerElements.push(el);
    if (STATE.config.zoomIndicator) {
      addZoomIndicator(el, STATE.config);
    }
    el.classList.add('parvus-trigger');
    el.addEventListener('click', triggerParvus);
    if (isOpen() && STATE.newGroup === STATE.activeGroup) {
      const EL_INDEX = STATE.GROUPS[STATE.newGroup].triggerElements.indexOf(el);
      createSlide(STATE, EL_INDEX);
      createImage(STATE, el, EL_INDEX, () => {
        loadImage(STATE, EL_INDEX);
      });
      updateAttributes(STATE);
      updateSliderNavigationStatus(STATE);
      updateCounter(STATE);
    }
  };

  /**
   * Remove an element
   *
   * @param {HTMLElement} el - The element to be removed
   */
  const remove = el => {
    if (!el || !el.hasAttribute('data-group')) {
      return;
    }
    const EL_GROUP = getGroup(STATE, el);
    const GROUP = STATE.GROUPS[EL_GROUP];

    // Check if element exists
    if (!GROUP) {
      return;
    }
    const EL_INDEX = GROUP.triggerElements.indexOf(el);
    if (EL_INDEX === -1) {
      return;
    }
    const IS_CURRENT_EL = isOpen() && EL_GROUP === STATE.activeGroup && EL_INDEX === STATE.currentIndex;

    // Remove group data
    if (GROUP.contentElements[EL_INDEX]) {
      const content = GROUP.contentElements[EL_INDEX];
      if (content.tagName === 'IMG') {
        content.src = '';
        content.srcset = '';
      }
    }

    // Remove DOM element
    const sliderElement = GROUP.sliderElements[EL_INDEX];
    if (sliderElement && sliderElement.parentNode) {
      sliderElement.parentNode.removeChild(sliderElement);
    }

    // Remove all array elements
    GROUP.triggerElements.splice(EL_INDEX, 1);
    GROUP.sliderElements.splice(EL_INDEX, 1);
    GROUP.contentElements.splice(EL_INDEX, 1);
    if (STATE.config.zoomIndicator) {
      removeZoomIndicator(el);
    }
    if (isOpen() && EL_GROUP === STATE.activeGroup) {
      if (IS_CURRENT_EL) {
        if (GROUP.triggerElements.length === 0) {
          close();
        } else if (STATE.currentIndex >= GROUP.triggerElements.length) {
          select(GROUP.triggerElements.length - 1);
        } else {
          updateAttributes(STATE);
          updateSliderNavigationStatus(STATE);
          updateCounter(STATE);
        }
      } else if (EL_INDEX < STATE.currentIndex) {
        STATE.currentIndex--;
        updateAttributes(STATE);
        updateSliderNavigationStatus(STATE);
        updateCounter(STATE);
      } else {
        updateAttributes(STATE);
        updateSliderNavigationStatus(STATE);
        updateCounter(STATE);
      }
    }

    // Unbind click event handler
    el.removeEventListener('click', triggerParvus);
    el.classList.remove('parvus-trigger');
  };

  /**
   * Open Parvus
   *
   * @param {HTMLElement} el
   */
  const open = el => {
    if (!STATE.lightbox || !el || !el.classList.contains('parvus-trigger') || isOpen()) {
      return;
    }
    STATE.activeGroup = getGroup(STATE, el);
    const GROUP = STATE.GROUPS[STATE.activeGroup];
    const EL_INDEX = GROUP.triggerElements.indexOf(el);
    if (EL_INDEX === -1) {
      throw new Error('Ups, element not found in group.');
    }
    STATE.currentIndex = EL_INDEX;
    history.pushState({
      parvus: 'close'
    }, 'Image', window.location.href);
    bindEvents();
    if (STATE.config.hideScrollbar) {
      document.body.style.marginInlineEnd = `${getScrollbarWidth()}px`;
      document.body.style.overflow = 'hidden';
    }
    STATE.lightbox.classList.add('parvus--is-opening');
    STATE.lightbox.showModal();
    createSlider(STATE);
    createSlide(STATE, STATE.currentIndex);
    updateOffset(STATE);
    updateAttributes(STATE);
    updateSliderNavigationStatus(STATE);
    updateCounter(STATE);
    loadSlide(STATE, STATE.currentIndex);
    createImage(STATE, el, STATE.currentIndex, () => {
      loadImage(STATE, STATE.currentIndex, true);
      STATE.lightbox.classList.remove('parvus--is-opening');
      GROUP.slider.classList.add('parvus__slider--animate');
    });
    preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex + 1);
    preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex - 1);

    // Execute afterOpen hook
    PLUGIN_MANAGER.executeHook('afterOpen', {
      element: el,
      state: STATE
    });

    // Create and dispatch a new event
    dispatchCustomEvent(STATE.lightbox, 'open');
  };

  /**
   * Close Parvus
   */
  const close = () => {
    if (!isOpen()) {
      return;
    }
    const IMAGE = STATE.GROUPS[STATE.activeGroup].contentElements[STATE.currentIndex];
    const THUMBNAIL = STATE.GROUPS[STATE.activeGroup].triggerElements[STATE.currentIndex];
    unbindEvents();
    STATE.clearDrag();
    if (history.state?.parvus === 'close') {
      history.back();
    }
    STATE.lightbox.classList.add('parvus--is-closing');
    const transitionendHandler = () => {
      // Reset the image zoom (if ESC was pressed or went back in the browser history)
      // after the ViewTransition (otherwise it looks bad)
      if (STATE.isPinching) {
        resetZoom(STATE, IMAGE);
      }
      leaveSlide(STATE, STATE.currentIndex);
      STATE.lightbox.close();
      STATE.lightbox.classList.remove('parvus--is-closing');
      STATE.lightbox.classList.remove('parvus--is-vertical-closing');
      STATE.GROUPS[STATE.activeGroup].slider.remove();
      STATE.GROUPS[STATE.activeGroup].slider = null;
      STATE.GROUPS[STATE.activeGroup].sliderElements = [];
      STATE.GROUPS[STATE.activeGroup].contentElements = [];
      STATE.counter.removeAttribute('aria-hidden');
      STATE.previousButton.removeAttribute('aria-hidden');
      STATE.previousButton.removeAttribute('aria-disabled');
      STATE.nextButton.removeAttribute('aria-hidden');
      STATE.nextButton.removeAttribute('aria-disabled');
      if (STATE.config.hideScrollbar) {
        document.body.style.marginInlineEnd = '';
        document.body.style.overflow = '';
      }

      // Execute afterClose hook
      PLUGIN_MANAGER.executeHook('afterClose', {
        state: STATE
      });
    };
    if (IMAGE && IMAGE.tagName === 'IMG') {
      if (document.startViewTransition) {
        IMAGE.style.viewTransitionName = 'lightboximage';
        const transition = document.startViewTransition(() => {
          IMAGE.style.opacity = '0';
          IMAGE.style.viewTransitionName = null;
          THUMBNAIL.style.viewTransitionName = 'lightboximage';
        });
        transition.finished.finally(() => {
          transitionendHandler();
          THUMBNAIL.style.viewTransitionName = null;
        });
      } else {
        IMAGE.style.opacity = '0';
        requestAnimationFrame(transitionendHandler);
      }
    } else {
      transitionendHandler();
    }
  };

  /**
   * Select a specific slide by index
   *
   * @param {number} index - Index of the slide to select
   */
  const select = index => {
    if (!isOpen()) {
      throw new Error("Oops, I'm closed.");
    }
    if (typeof index !== 'number' || isNaN(index)) {
      throw new Error('Oops, no slide specified.');
    }
    const GROUP = STATE.GROUPS[STATE.activeGroup];
    const triggerElements = GROUP.triggerElements;
    if (index === STATE.currentIndex) {
      throw new Error(`Oops, slide ${index} is already selected.`);
    }
    if (index < 0 || index >= triggerElements.length) {
      throw new Error(`Oops, I can't find slide ${index}.`);
    }
    const OLD_INDEX = STATE.currentIndex;
    STATE.currentIndex = index;
    if (GROUP.sliderElements[index]) {
      loadSlide(STATE, index);
    } else {
      createSlide(STATE, index);
      createImage(STATE, GROUP.triggerElements[index], index, () => {
        loadImage(STATE, index);
      });
      loadSlide(STATE, index);
    }
    updateOffset(STATE);
    updateSliderNavigationStatus(STATE);
    updateCounter(STATE);

    // Execute slideChange hook
    PLUGIN_MANAGER.executeHook('slideChange', {
      index,
      oldIndex: OLD_INDEX,
      state: STATE
    });
    if (index < OLD_INDEX) {
      preload(STATE, createSlide, createImage, loadImage, index - 1);
    } else {
      preload(STATE, createSlide, createImage, loadImage, index + 1);
    }
    leaveSlide(STATE, OLD_INDEX);

    // Create and dispatch a new event
    dispatchCustomEvent(STATE.lightbox, 'select');
  };

  /**
   * Select the previous slide
   */
  const previous = () => {
    if (STATE.currentIndex > 0) {
      select(STATE.currentIndex - 1);
    }
  };

  /**
   * Select the next slide
   */
  const next = () => {
    const {
      triggerElements
    } = STATE.GROUPS[STATE.activeGroup];
    if (STATE.currentIndex < triggerElements.length - 1) {
      select(STATE.currentIndex + 1);
    }
  };

  /**
   * Bind specified events
   */
  const bindEvents = () => {
    const actions = {
      close,
      previous,
      next,
      updateOffset: () => updateOffset(STATE)
    };

    // Create handlers with state and actions
    keydownHandler = createKeydownHandler(STATE, actions);
    clickHandler = createClickHandler(STATE, actions);
    resizeHandler = createResizeHandler(STATE, () => updateOffset(STATE));
    const updateAfterDragHandler = () => updateAfterDrag(STATE, actions);
    const pinchZoomHandler = img => pinchZoom(STATE, img);
    const doSwipeHandler = () => doSwipe(STATE);
    const resetZoomHandler = img => resetZoom(STATE, img);
    pointerdownHandler = createPointerdownHandler(STATE);
    pointermoveHandler = createPointermoveHandler(STATE, pinchZoomHandler, doSwipeHandler);
    pointerupHandler = createPointerupHandler(STATE, resetZoomHandler, updateAfterDragHandler);
    BROWSER_WINDOW.addEventListener('keydown', keydownHandler);
    BROWSER_WINDOW.addEventListener('resize', resizeHandler);

    // Popstate event
    BROWSER_WINDOW.addEventListener('popstate', close);

    // Check for any OS level changes to the prefers reduced motion preference
    MOTIONQUERY.addEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY));

    // Click event
    STATE.lightbox.addEventListener('click', clickHandler);

    // Pointer events
    STATE.lightbox.addEventListener('pointerdown', pointerdownHandler, {
      passive: false
    });
    STATE.lightbox.addEventListener('pointerup', pointerupHandler, {
      passive: true
    });
    STATE.lightbox.addEventListener('pointermove', pointermoveHandler, {
      passive: false
    });
  };

  /**
   * Unbind specified events
   */
  const unbindEvents = () => {
    BROWSER_WINDOW.removeEventListener('keydown', keydownHandler);
    BROWSER_WINDOW.removeEventListener('resize', resizeHandler);

    // Popstate event
    BROWSER_WINDOW.removeEventListener('popstate', close);

    // Check for any OS level changes to the prefers reduced motion preference
    MOTIONQUERY.removeEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY));

    // Click event
    STATE.lightbox.removeEventListener('click', clickHandler);

    // Pointer events
    STATE.lightbox.removeEventListener('pointerdown', pointerdownHandler);
    STATE.lightbox.removeEventListener('pointerup', pointerupHandler);
    STATE.lightbox.removeEventListener('pointermove', pointermoveHandler);
  };

  /**
   * Destroy Parvus
   */
  const destroy = () => {
    if (!STATE.lightbox) {
      return;
    }
    if (isOpen()) {
      close();
    }

    // Add setTimeout to ensure all possible close transitions are completed
    setTimeout(() => {
      unbindEvents();

      // Remove all registered event listeners for custom events
      const eventTypes = ['open', 'close', 'select', 'destroy'];
      eventTypes.forEach(eventType => {
        const listeners = STATE.lightbox._listeners?.[eventType] || [];
        listeners.forEach(listener => {
          STATE.lightbox.removeEventListener(eventType, listener);
        });
      });

      // Remove event listeners from trigger elements
      const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll('.parvus-trigger');
      LIGHTBOX_TRIGGER_ELS.forEach(el => {
        el.removeEventListener('click', triggerParvus);
        el.classList.remove('parvus-trigger');
        if (STATE.config.zoomIndicator) {
          removeZoomIndicator(el);
        }
        if (el.dataset.group) {
          delete el.dataset.group;
        }
      });

      // Create and dispatch a new event
      dispatchCustomEvent(STATE.lightbox, 'destroy');
      STATE.lightbox.remove();

      // Remove references
      STATE.lightbox = null;
      STATE.lightboxOverlay = null;
      STATE.toolbar = null;
      STATE.toolbarLeft = null;
      STATE.toolbarRight = null;
      STATE.controls = null;
      STATE.previousButton = null;
      STATE.nextButton = null;
      STATE.closeButton = null;
      STATE.counter = null;

      // Remove group data
      Object.keys(STATE.GROUPS).forEach(groupKey => {
        const group = STATE.GROUPS[groupKey];
        if (group && group.contentElements) {
          group.contentElements.forEach(content => {
            if (content && content.tagName === 'IMG') {
              content.src = '';
              content.srcset = '';
            }
          });
        }
        delete STATE.GROUPS[groupKey];
      });

      // Reset variables
      STATE.groupIdCounter = 0;
      STATE.newGroup = null;
      STATE.activeGroup = null;
      STATE.currentIndex = 0;
    }, 1000);
  };

  /**
   * Check if Parvus is open
   *
   * @returns {boolean} - True if Parvus is open, otherwise false
   */
  const isOpen = () => {
    return STATE.lightbox?.hasAttribute('open');
  };

  /**
   * Get the current index
   *
   * @returns {number} - The current index
   */
  const getCurrentIndex = () => {
    return STATE.currentIndex;
  };

  /**
   * Bind a specific event listener
   *
   * @param {String} eventName - The name of the event to bind
   * @param {Function} callback - The callback function
   */
  const on$1 = (eventName, callback) => {
    on(STATE.lightbox, eventName, callback);
  };

  /**
   * Unbind a specific event listener
   *
   * @param {String} eventName - The name of the event to unbind
   * @param {Function} callback - The callback function
   */
  const off$1 = (eventName, callback) => {
    off(STATE.lightbox, eventName, callback);
  };

  /**
   * Use a plugin
   *
   * @param {Object} plugin - Plugin object
   * @param {Object} options - Plugin options
   */
  const use = (plugin, options = {}) => {
    PLUGIN_MANAGER.register(plugin, options);
  };

  /**
   * Add a hook callback
   *
   * @param {String} hookName - Hook name
   * @param {Function} callback - Callback function
   */
  const addHook = (hookName, callback) => {
    PLUGIN_MANAGER.addHook(hookName, callback);
  };

  /**
   * Get registered plugins
   *
   * @returns {Array} Array of plugin names
   */
  const getPlugins = () => {
    return PLUGIN_MANAGER.getPlugins();
  };

  /**
   * Init
   */
  const init = () => {
    // Merge user options into defaults
    STATE.config = mergeOptions(userOptions);
    reducedMotionCheck(STATE, MOTIONQUERY);

    // Install plugins with context
    const pluginContext = {
      state: STATE,
      on: on,
      addHook: PLUGIN_MANAGER.addHook.bind(PLUGIN_MANAGER),
      config: STATE.config
    };
    PLUGIN_MANAGER.install(pluginContext);
    if (STATE.config.gallerySelector !== null) {
      // Get a list of all `gallerySelector` elements within the document
      const GALLERY_ELS = document.querySelectorAll(STATE.config.gallerySelector);

      // Execute a few things once per element
      GALLERY_ELS.forEach((galleryEl, index) => {
        const GALLERY_INDEX = index;
        // Get a list of all `selector` elements within the `gallerySelector`
        const LIGHTBOX_TRIGGER_GALLERY_ELS = galleryEl.querySelectorAll(STATE.config.selector);

        // Execute a few things once per element
        LIGHTBOX_TRIGGER_GALLERY_ELS.forEach(lightboxTriggerEl => {
          lightboxTriggerEl.setAttribute('data-group', `parvus-gallery-${GALLERY_INDEX}`);
          add(lightboxTriggerEl);
        });
      });
    }

    // Get a list of all `selector` elements outside or without the `gallerySelector`
    const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(`${STATE.config.selector}:not(.parvus-trigger)`);
    LIGHTBOX_TRIGGER_ELS.forEach(add);
  };
  init();
  return {
    init,
    open,
    close,
    select,
    previous,
    next,
    currentIndex: getCurrentIndex,
    add,
    remove,
    destroy,
    isOpen,
    on: on$1,
    off: off$1,
    use,
    addHook,
    getPlugins
  };
}

export { Parvus as default };


================================================
FILE: dist/js/parvus.js
================================================
/**
 * Parvus
 *
 * @author Benjamin de Oostfrees
 * @version 3.1.0
 * @url https://github.com/deoostfrees/parvus
 *
 * MIT license
 */

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
  typeof define === 'function' && define.amd ? define(factory) :
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Parvus = factory());
})(this, (function () { 'use strict';

  const BROWSER_WINDOW = window;

  /**
   * Get scrollbar width
   *
   * @return {Number} - The scrollbar width
   */
  const getScrollbarWidth = () => {
    return BROWSER_WINDOW.innerWidth - document.documentElement.clientWidth;
  };
  const FOCUSABLE_ELEMENTS = ['a:not([inert]):not([tabindex^="-"])', 'button:not([inert]):not([tabindex^="-"]):not(:disabled)', '[tabindex]:not([inert]):not([tabindex^="-"])'];

  /**
   * Get the focusable children of the given element
   *
   * @return {Array<Element>} - An array of focusable children
   */
  const getFocusableChildren = targetEl => {
    return Array.from(targetEl.querySelectorAll(FOCUSABLE_ELEMENTS.join(', '))).filter(child => child.offsetParent !== null);
  };

  var en = {
    lightboxLabel: 'This is a dialog window that overlays the main content of the page. The modal displays the enlarged image. Pressing the Escape key will close the modal and bring you back to where you were on the page.',
    lightboxLoadingIndicatorLabel: 'Image loading',
    lightboxLoadingError: 'The requested image cannot be loaded.',
    controlsLabel: 'Controls',
    previousButtonLabel: 'Previous image',
    nextButtonLabel: 'Next image',
    closeButtonLabel: 'Close dialog window',
    sliderLabel: 'Images',
    slideLabel: 'Image'
  };

  /**
   * Default configuration options
   */
  const DEFAULT_OPTIONS = {
    selector: '.lightbox',
    gallerySelector: null,
    zoomIndicator: true,
    captions: true,
    captionsSelector: 'self',
    captionsAttribute: 'data-caption',
    copyright: true,
    copyrightSelector: 'self',
    copyrightAttribute: 'data-copyright',
    docClose: true,
    swipeClose: true,
    simulateTouch: true,
    threshold: 50,
    hideScrollbar: true,
    lightboxIndicatorIcon: '<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>',
    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>',
    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>',
    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>',
    l10n: en
  };

  /**
   * Merge default options with user-provided options
   *
   * @param {Object} userOptions - User-provided options
   * @returns {Object} - Merged options object
   */
  const mergeOptions = userOptions => {
    const MERGED_OPTIONS = {
      ...DEFAULT_OPTIONS,
      ...userOptions
    };
    if (userOptions && userOptions.l10n) {
      MERGED_OPTIONS.l10n = {
        ...DEFAULT_OPTIONS.l10n,
        ...userOptions.l10n
      };
    }
    return MERGED_OPTIONS;
  };

  /**
   * State management for Parvus
   *
   * Centralizes all mutable state variables
   */
  class ParvusState {
    constructor() {
      // Group management
      this.GROUP_ATTRIBUTES = {
        triggerElements: [],
        slider: null,
        sliderElements: [],
        contentElements: []
      };
      this.GROUPS = {};
      this.groupIdCounter = 0;
      this.newGroup = null;
      this.activeGroup = null;
      this.currentIndex = 0;

      // Configuration
      this.config = {};

      // DOM elements
      this.lightbox = null;
      this.lightboxOverlay = null;
      this.lightboxOverlayOpacity = 1;
      this.toolbar = null;
      this.toolbarLeft = null;
      this.toolbarRight = null;
      this.controls = null;
      this.previousButton = null;
      this.nextButton = null;
      this.closeButton = null;
      this.counter = null;

      // Drag & interaction state
      this.drag = {};
      this.isDraggingX = false;
      this.isDraggingY = false;
      this.pointerDown = false;
      this.activePointers = new Map();

      // Zoom state
      this.currentScale = 1;
      this.isPinching = false;
      this.isTap = false;
      this.pinchStartDistance = 0;
      this.lastPointersId = null;

      // Offset & animation
      this.offset = null;
      this.offsetTmp = null;
      this.resizeTicking = false;
      this.isReducedMotion = true;
    }

    /**
     * Clear drag state
     */
    clearDrag() {
      this.drag = {
        startX: 0,
        endX: 0,
        startY: 0,
        endY: 0
      };
    }

    /**
     * Get the active group
     *
     * @returns {Object} The active group
     */
    getActiveGroup() {
      return this.GROUPS[this.activeGroup];
    }

    /**
     * Reset zoom state
     */
    resetZoomState() {
      this.isPinching = false;
      this.isTap = false;
      this.currentScale = 1;
      this.pinchStartDistance = 0;
      this.lastPointersId = '';
    }
  }

  /**
   * Event System Module
   *
   * Handles custom event dispatching and listeners
   */

  /**
   * Dispatch a custom event
   *
   * @param {HTMLElement} lightbox - The lightbox element
   * @param {String} type - The type of the event to dispatch
   * @returns {void}
   */
  const dispatchCustomEvent = (lightbox, type) => {
    const CUSTOM_EVENT = new CustomEvent(type, {
      cancelable: true
    });
    lightbox.dispatchEvent(CUSTOM_EVENT);
  };

  /**
   * Bind a specific event listener
   *
   * @param {HTMLElement} lightbox - The lightbox element
   * @param {String} eventName - The name of the event to bind
   * @param {Function} callback - The callback function
   * @returns {void}
   */
  const on = (lightbox, eventName, callback) => {
    if (lightbox) {
      lightbox.addEventListener(eventName, callback);
    }
  };

  /**
   * Unbind a specific event listener
   *
   * @param {HTMLElement} lightbox - The lightbox element
   * @param {String} eventName - The name of the event to unbind
   * @param {Function} callback - The callback function
   * @returns {void}
   */
  const off = (lightbox, eventName, callback) => {
    if (lightbox) {
      lightbox.removeEventListener(eventName, callback);
    }
  };

  /**
   * Navigation Module
   *
   * Handles slide navigation and transitions
   */

  /**
   * Update offset
   *
   * @param {Object} state - The application state
   * @returns {void}
   */
  const updateOffset = state => {
    state.activeGroup = state.activeGroup !== null ? state.activeGroup : state.newGroup;
    state.offset = -state.currentIndex * state.lightbox.offsetWidth;
    state.GROUPS[state.activeGroup].slider.style.transform = `translate3d(${state.offset}px, 0, 0)`;
    state.offsetTmp = state.offset;
  };

  /**
   * Load slide with the specified index
   *
   * @param {Object} state - The application state
   * @param {Number} index - The index of the slide to be loaded
   * @returns {void}
   */
  const loadSlide = (state, index) => {
    state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'false');
  };

  /**
   * Leave slide
   *
   * @param {Object} state - The application state
   * @param {Number} index - The index of the slide to leave
   * @returns {void}
   */
  const leaveSlide = (state, index) => {
    if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
      state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'true');
    }
  };

  /**
   * Preload slide with the specified index
   *
   * @param {Object} state - The application state
   * @param {Function} createSlide - Create slide function
   * @param {Function} createImage - Create image function
   * @param {Function} loadImage - Load image function
   * @param {Number} index - The index of the slide to be preloaded
   * @returns {void}
   */
  const preload = (state, createSlide, createImage, loadImage, index) => {
    if (index < 0 || index >= state.GROUPS[state.activeGroup].triggerElements.length || state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
      return;
    }
    createSlide(state, index);
    createImage(state, state.GROUPS[state.activeGroup].triggerElements[index], index, () => {
      loadImage(state, index);
    });
  };

  /**
   * Utils Module
   *
   * Utility functions
   */

  /**
   * Check prefers reduced motion
   *
   * @param {Object} state - The application state
   * @param {MediaQueryList} motionQuery - The media query list
   * @returns {void}
   */
  const reducedMotionCheck = (state, motionQuery) => {
    if (motionQuery.matches) {
      state.isReducedMotion = true;
    } else {
      state.isReducedMotion = false;
    }
  };

  /**
   * Retrieves or creates a group identifier for the given element
   *
   * @param {Object} state - The application state
   * @param {HTMLElement} el - DOM element to get or assign a group to
   * @returns {string} The group identifier associated with the element
   */
  const getGroup = (state, el) => {
    // Return existing group identifier if already assigned
    if (el.dataset.group) {
      return el.dataset.group;
    }

    // Generate new unique group identifier using counter
    const EL_GROUP = `default-${state.groupIdCounter++}`;

    // Assign the new group identifier to element's dataset
    el.dataset.group = EL_GROUP;
    return EL_GROUP;
  };

  /**
   * Plugin management for Parvus
   *
   * Provides a system for registering and managing plugins
   */

  class PluginManager {
    constructor() {
      this.plugins = [];
      this.hooks = {};
      this.context = null;
      this.isInitialized = false;
    }

    /**
     * Register a plugin
     *
     * @param {Object} plugin - Plugin object with name and install function
     * @param {Object} options - Plugin-specific options
     */
    register(plugin, options = {}) {
      if (!plugin || typeof plugin.install !== 'function') {
        throw new Error('Plugin must have an install function');
      }
      if (!plugin.name) {
        throw new Error('Plugin must have a name');
      }

      // Check if plugin is already registered
      const existingPlugin = this.plugins.find(p => p.name === plugin.name);
      if (existingPlugin) {
        console.warn(`Plugin "${plugin.name}" is already registered`);
        return;
      }
      this.plugins.push({
        plugin,
        options
      });

      // If already initialized, install immediately
      if (this.isInitialized && this.context) {
        this.installPlugin(plugin, options);
      }
    }

    /**
     * Install a single plugin
     *
     * @param {Object} plugin - Plugin object
     * @param {Object} options - Plugin options
     */
    installPlugin(plugin, options) {
      try {
        plugin.install(this.context, options);

        // If lightbox already exists, execute afterInit hook for this plugin immediately
        if (this.context && this.context.state && this.context.state.lightbox) {
          this.executeHook('afterInit', {
            state: this.context.state
          });
        }
      } catch (error) {
        console.error(`Failed to install plugin "${plugin.name}":`, error);
      }
    }

    /**
     * Install all registered plugins
     *
     * @param {Object} context - Parvus instance context
     */
    install(context) {
      this.context = context;
      this.isInitialized = true;
      this.plugins.forEach(({
        plugin,
        options
      }) => {
        this.installPlugin(plugin, options);
      });
    }

    /**
     * Execute a hook
     *
     * @param {String} hookName - Name of the hook
     * @param {*} data - Data to pass to hook callbacks
     */
    executeHook(hookName, data) {
      const callbacks = this.hooks[hookName] || [];
      callbacks.forEach(callback => {
        try {
          callback(data);
        } catch (error) {
          console.error(`Error in hook "${hookName}":`, error);
        }
      });
    }

    /**
     * Register a hook callback
     *
     * @param {String} hookName - Name of the hook
     * @param {Function} callback - Callback function
     */
    addHook(hookName, callback) {
      if (!this.hooks[hookName]) {
        this.hooks[hookName] = [];
      }
      this.hooks[hookName].push(callback);
    }

    /**
     * Remove a hook callback
     *
     * @param {String} hookName - Name of the hook
     * @param {Function} callback - Callback function to remove
     */
    removeHook(hookName, callback) {
      if (!this.hooks[hookName]) return;
      this.hooks[hookName] = this.hooks[hookName].filter(cb => cb !== callback);
    }

    /**
     * Get all registered plugins
     *
     * @returns {Array} Array of plugin names
     */
    getPlugins() {
      return this.plugins.map(p => p.plugin.name);
    }
  }

  /**
   * UI Components Module
   *
   * Handles creation of lightbox, toolbar, slider and slides
   */

  /**
   * Create the lightbox
   *
   * @param {Object} state - The application state
   * @returns {void}
   */
  const createLightbox = state => {
    const {
      config
    } = state;

    // Use DocumentFragment to batch DOM operations
    const fragment = document.createDocumentFragment();

    // Create the lightbox container
    state.lightbox = document.createElement('dialog');
    state.lightbox.setAttribute('role', 'dialog');
    state.lightbox.setAttribute('aria-modal', 'true');
    state.lightbox.setAttribute('aria-label', config.l10n.lightboxLabel);
    state.lightbox.classList.add('parvus');

    // Create the lightbox overlay container
    state.lightboxOverlay = document.createElement('div');
    state.lightboxOverlay.classList.add('parvus__overlay');

    // Create the toolbar
    state.toolbar = document.createElement('div');
    state.toolbar.className = 'parvus__toolbar';

    // Create the toolbar items
    state.toolbarLeft = document.createElement('div');
    state.toolbarRight = document.createElement('div');

    // Create the controls
    state.controls = document.createElement('div');
    state.controls.className = 'parvus__controls';
    state.controls.setAttribute('role', 'group');
    state.controls.setAttribute('aria-label', config.l10n.controlsLabel);

    // Create the close button
    state.closeButton = document.createElement('button');
    state.closeButton.className = 'parvus__btn parvus__btn--close';
    state.closeButton.setAttribute('type', 'button');
    state.closeButton.setAttribute('aria-label', config.l10n.closeButtonLabel);
    state.closeButton.innerHTML = config.closeButtonIcon;

    // Create the previous button
    state.previousButton = document.createElement('button');
    state.previousButton.className = 'parvus__btn parvus__btn--previous';
    state.previousButton.setAttribute('type', 'button');
    state.previousButton.setAttribute('aria-label', config.l10n.previousButtonLabel);
    state.previousButton.innerHTML = config.previousButtonIcon;

    // Create the next button
    state.nextButton = document.createElement('button');
    state.nextButton.className = 'parvus__btn parvus__btn--next';
    state.nextButton.setAttribute('type', 'button');
    state.nextButton.setAttribute('aria-label', config.l10n.nextButtonLabel);
    state.nextButton.innerHTML = config.nextButtonIcon;

    // Create the counter
    state.counter = document.createElement('div');
    state.counter.className = 'parvus__counter';

    // Add the control buttons to the controls
    state.controls.append(state.closeButton, state.previousButton, state.nextButton);

    // Add the counter to the left toolbar item
    state.toolbarLeft.appendChild(state.counter);

    // Add the controls to the right toolbar item
    state.toolbarRight.appendChild(state.controls);

    // Add the toolbar items to the toolbar
    state.toolbar.append(state.toolbarLeft, state.toolbarRight);

    // Add the overlay and the toolbar to the lightbox
    state.lightbox.append(state.lightboxOverlay, state.toolbar);
    fragment.appendChild(state.lightbox);

    // Add to document body
    document.body.appendChild(fragment);
  };

  /**
   * Create a slider
   *
   * @param {Object} state - The application state
   * @returns {void}
   */
  const createSlider = state => {
    const SLIDER = document.createElement('div');
    SLIDER.className = 'parvus__slider';

    // Update the slider reference in GROUPS
    state.GROUPS[state.activeGroup].slider = SLIDER;

    // Add the slider to the lightbox container
    state.lightbox.appendChild(SLIDER);
  };

  /**
   * Get next slide index
   *
   * @param {Object} state - The application state
   * @param {Number} currentIndex - Current slide index
   * @returns {number} Index of the next available slide or -1 if none found
   */
  const getNextSlideIndex = (state, currentIndex) => {
    const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;
    const TOTAL_SLIDE_ELEMENTS = SLIDE_ELEMENTS.length;
    for (let i = currentIndex + 1; i < TOTAL_SLIDE_ELEMENTS; i++) {
      if (SLIDE_ELEMENTS[i] !== undefined) {
        return i;
      }
    }
    return -1;
  };

  /**
   * Get previous slide index
   *
   * @param {Object} state - The application state
   * @param {number} currentIndex - Current slide index
   * @returns {number} Index of the previous available slide or -1 if none found
   */
  const getPreviousSlideIndex = (state, currentIndex) => {
    const SLIDE_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;
    for (let i = currentIndex - 1; i >= 0; i--) {
      if (SLIDE_ELEMENTS[i] !== undefined) {
        return i;
      }
    }
    return -1;
  };

  /**
   * Create a slide
   *
   * @param {Object} state - The application state
   * @param {Number} index - The index of the slide
   * @returns {void}
   */
  const createSlide = (state, index) => {
    if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
      return;
    }
    const FRAGMENT = document.createDocumentFragment();
    const SLIDE_ELEMENT = document.createElement('div');
    const SLIDE_ELEMENT_CONTENT = document.createElement('div');
    const GROUP = state.GROUPS[state.activeGroup];
    const TOTAL_TRIGGER_ELEMENTS = GROUP.triggerElements.length;
    SLIDE_ELEMENT.className = 'parvus__slide';
    SLIDE_ELEMENT.style.cssText = `
    position: absolute;
    left: ${index * 100}%;
  `;
    SLIDE_ELEMENT.setAttribute('aria-hidden', 'true');

    // Add accessibility attributes if gallery has multiple slides
    if (TOTAL_TRIGGER_ELEMENTS > 1) {
      SLIDE_ELEMENT.setAttribute('role', 'group');
      SLIDE_ELEMENT.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`);
    }
    SLIDE_ELEMENT.appendChild(SLIDE_ELEMENT_CONTENT);
    FRAGMENT.appendChild(SLIDE_ELEMENT);
    GROUP.sliderElements[index] = SLIDE_ELEMENT;

    // Insert the slide element based on index position
    if (index >= state.currentIndex) {
      // Insert the slide element after the current slide
      const NEXT_SLIDE_INDEX = getNextSlideIndex(state, index);
      if (NEXT_SLIDE_INDEX !== -1) {
        GROUP.sliderElements[NEXT_SLIDE_INDEX].before(SLIDE_ELEMENT);
      } else {
        GROUP.slider.appendChild(SLIDE_ELEMENT);
      }
    } else {
      // Insert the slide element before the current slide
      const PREVIOUS_SLIDE_INDEX = getPreviousSlideIndex(state, index);
      if (PREVIOUS_SLIDE_INDEX !== -1) {
        GROUP.sliderElements[PREVIOUS_SLIDE_INDEX].after(SLIDE_ELEMENT);
      } else {
        GROUP.slider.prepend(SLIDE_ELEMENT);
      }
    }
  };

  /**
   * Update counter
   *
   * @param {Object} state - The application state
   * @returns {void}
   */
  const updateCounter = state => {
    state.counter.textContent = `${state.currentIndex + 1}/${state.GROUPS[state.activeGroup].triggerElements.length}`;
  };

  /**
   * Update Attributes
   *
   * @param {Object} state - The application state
   * @returns {void}
   */
  const updateAttributes = state => {
    const TRIGGER_ELEMENTS = state.GROUPS[state.activeGroup].triggerElements;
    const TOTAL_TRIGGER_ELEMENTS = TRIGGER_ELEMENTS.length;
    const SLIDER = state.GROUPS[state.activeGroup].slider;
    const SLIDER_ELEMENTS = state.GROUPS[state.activeGroup].sliderElements;
    const IS_DRAGGABLE = SLIDER.classList.contains('parvus__slider--is-draggable');

    // Add draggable class if necessary
    if (state.config.simulateTouch && state.config.swipeClose && !IS_DRAGGABLE || state.config.simulateTouch && TOTAL_TRIGGER_ELEMENTS > 1 && !IS_DRAGGABLE) {
      SLIDER.classList.add('parvus__slider--is-draggable');
    } else {
      SLIDER.classList.remove('parvus__slider--is-draggable');
    }

    // Add extra output for screen reader if there is more than one slide
    if (TOTAL_TRIGGER_ELEMENTS > 1) {
      SLIDER.setAttribute('role', 'region');
      SLIDER.setAttribute('aria-roledescription', 'carousel');
      SLIDER.setAttribute('aria-label', state.config.l10n.sliderLabel);
      SLIDER_ELEMENTS.forEach((sliderElement, index) => {
        sliderElement.setAttribute('role', 'group');
        sliderElement.setAttribute('aria-label', `${state.config.l10n.slideLabel} ${index + 1}/${TOTAL_TRIGGER_ELEMENTS}`);
      });
    } else {
      SLIDER.removeAttribute('role');
      SLIDER.removeAttribute('aria-roledescription');
      SLIDER.removeAttribute('aria-label');
      SLIDER_ELEMENTS.forEach(sliderElement => {
        sliderElement.removeAttribute('role');
        sliderElement.removeAttribute('aria-label');
      });
    }

    // Show or hide buttons
    if (TOTAL_TRIGGER_ELEMENTS === 1) {
      state.counter.setAttribute('aria-hidden', 'true');
      state.previousButton.setAttribute('aria-hidden', 'true');
      state.nextButton.setAttribute('aria-hidden', 'true');
    } else {
      state.counter.removeAttribute('aria-hidden');
      state.previousButton.removeAttribute('aria-hidden');
      state.nextButton.removeAttribute('aria-hidden');
    }
  };

  /**
   * Update slider navigation status
   *
   * @param {Object} state - The application state
   * @returns {void}
   */
  const updateSliderNavigationStatus = state => {
    const {
      triggerElements
    } = state.GROUPS[state.activeGroup];
    const TOTAL_TRIGGER_ELEMENTS = triggerElements.length;
    if (TOTAL_TRIGGER_ELEMENTS <= 1) {
      return;
    }

    // Determine navigation state
    const FIRST_SLIDE = state.currentIndex === 0;
    const LAST_SLIDE = state.currentIndex === TOTAL_TRIGGER_ELEMENTS - 1;

    // Set previous button state
    const PREV_DISABLED = FIRST_SLIDE ? 'true' : null;
    if (state.previousButton.getAttribute('aria-disabled') === 'true' !== !!PREV_DISABLED) {
      PREV_DISABLED ? state.previousButton.setAttribute('aria-disabled', 'true') : state.previousButton.removeAttribute('aria-disabled');
    }

    // Set next button state
    const NEXT_DISABLED = LAST_SLIDE ? 'true' : null;
    if (state.nextButton.getAttribute('aria-disabled') === 'true' !== !!NEXT_DISABLED) {
      NEXT_DISABLED ? state.nextButton.setAttribute('aria-disabled', 'true') : state.nextButton.removeAttribute('aria-disabled');
    }
  };

  /**
   * Add zoom indicator to element
   *
   * @param {HTMLElement} el - The element to add the zoom indicator to
   * @param {Object} config - Options object
   */
  const addZoomIndicator = (el, config) => {
    if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') === null) {
      const LIGHTBOX_INDICATOR_ICON = document.createElement('div');
      LIGHTBOX_INDICATOR_ICON.className = 'parvus-zoom__indicator';
      LIGHTBOX_INDICATOR_ICON.innerHTML = config.lightboxIndicatorIcon;
      el.appendChild(LIGHTBOX_INDICATOR_ICON);
    }
  };

  /**
   * Remove zoom indicator for element
   *
   * @param {HTMLElement} el - The element to remove the zoom indicator to
   */
  const removeZoomIndicator = el => {
    if (el.querySelector('img') && el.querySelector('.parvus-zoom__indicator') !== null) {
      const LIGHTBOX_INDICATOR_ICON = el.querySelector('.parvus-zoom__indicator');
      el.removeChild(LIGHTBOX_INDICATOR_ICON);
    }
  };

  /**
   * Keyboard Event Handler Module
   *
   * Handles all keyboard interactions
   */


  /**
   * Create keyboard event handler
   *
   * @param {Object} state - The application state
   * @param {Object} actions - Actions object with navigation functions
   * @returns {Function} Keyboard event handler
   */
  const createKeydownHandler = (state, actions) => {
    return event => {
      const FOCUSABLE_CHILDREN = getFocusableChildren(state.lightbox);
      const FOCUSED_ITEM_INDEX = FOCUSABLE_CHILDREN.indexOf(document.activeElement);
      const lastIndex = FOCUSABLE_CHILDREN.length - 1;
      switch (event.code) {
        case 'Tab':
          {
            // Use the TAB key to navigate backwards and forwards
            if (event.shiftKey) {
              // Navigate backwards
              if (FOCUSED_ITEM_INDEX === 0) {
                FOCUSABLE_CHILDREN[lastIndex].focus();
                event.preventDefault();
              }
            } else {
              // Navigate forwards
              if (FOCUSED_ITEM_INDEX === lastIndex) {
                FOCUSABLE_CHILDREN[0].focus();
                event.preventDefault();
              }
            }
            break;
          }
        case 'Escape':
          {
            // Close Parvus when the ESC key is pressed
            actions.close();
            event.preventDefault();
            break;
          }
        case 'ArrowLeft':
          {
            // Show the previous slide when the PREV key is pressed
            actions.previous();
            event.preventDefault();
            break;
          }
        case 'ArrowRight':
          {
            // Show the next slide when the NEXT key is pressed
            actions.next();
            event.preventDefault();
            break;
          }
      }
    };
  };

  /**
   * Pointer Event Handler Module
   *
   * Handles all pointer interactions (mouse, touch, pen)
   */

  /**
   * Create pointerdown event handler
   *
   * @param {Object} state - The application state
   * @returns {Function} Pointerdown event handler
   */
  const createPointerdownHandler = state => {
    return event => {
      event.preventDefault();
      event.stopPropagation();
      state.isDraggingX = false;
      state.isDraggingY = false;
      state.pointerDown = true;
      state.activePointers.set(event.pointerId, event);
      state.drag.startX = event.pageX;
      state.drag.startY = event.pageY;
      state.drag.endX = event.pageX;
      state.drag.endY = event.pageY;
      const {
        slider
      } = state.GROUPS[state.activeGroup];
      slider.classList.add('parvus__slider--is-dragging');
      slider.style.willChange = 'transform';
      state.isTap = state.activePointers.size === 1;
      if (state.config.swipeClose) {
        state.lightboxOverlayOpacity = getComputedStyle(state.lightboxOverlay).opacity;
      }
    };
  };

  /**
   * Create pointermove event handler
   *
   * @param {Object} state - The application state
   * @param {Function} pinchZoom - Pinch zoom function
   * @param {Function} doSwipe - Swipe function
   * @returns {Function} Pointermove event handler
   */
  const createPointermoveHandler = (state, pinchZoom, doSwipe) => {
    return event => {
      event.preventDefault();
      if (!state.pointerDown) {
        return;
      }
      const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex];

      // Update pointer position
      state.activePointers.set(event.pointerId, event);

      // Zoom
      if (CURRENT_IMAGE && CURRENT_IMAGE.tagName === 'IMG') {
        if (state.activePointers.size === 2) {
          pinchZoom(CURRENT_IMAGE);
          return;
        }
        if (state.currentScale > 1) {
          return;
        }
      }
      state.drag.endX = event.pageX;
      state.drag.endY = event.pageY;
      doSwipe();
    };
  };

  /**
   * Create pointerup event handler
   *
   * @param {Object} state - The application state
   * @param {Function} resetZoom - Reset zoom function
   * @param {Function} updateAfterDrag - Update after drag function
   * @returns {Function} Pointerup event handler
   */
  const createPointerupHandler = (state, resetZoom, updateAfterDrag) => {
    return event => {
      event.stopPropagation();
      const {
        slider
      } = state.GROUPS[state.activeGroup];
      state.activePointers.delete(event.pointerId);
      if (state.activePointers.size > 0) {
        return;
      }
      state.pointerDown = false;
      const CURRENT_IMAGE = state.GROUPS[state.activeGroup].contentElements[state.currentIndex];

      // Reset zoom state by one tap
      const MOVEMENT_X = Math.abs(state.drag.endX - state.drag.startX);
      const MOVEMENT_Y = Math.abs(state.drag.endY - state.drag.startY);
      const IS_TAP = MOVEMENT_X < 8 && MOVEMENT_Y < 8 && !state.isDraggingX && !state.isDraggingY && state.isTap;
      slider.classList.remove('parvus__slider--is-dragging');
      slider.style.willChange = '';
      if (state.currentScale > 1) {
        if (IS_TAP) {
          resetZoom(CURRENT_IMAGE);
        } else {
          CURRENT_IMAGE.style.transform = `
          scale(${state.currentScale})
        `;
        }
      } else {
        if (state.isPinching) {
          resetZoom(CURRENT_IMAGE);
        }
        if (state.drag.endX || state.drag.endY) {
          updateAfterDrag();
        }
      }
      state.clearDrag();
    };
  };

  /**
   * Create click event handler
   *
   * @param {Object} state - The application state
   * @param {Object} actions - Actions object with navigation functions
   * @returns {Function} Click event handler
   */
  const createClickHandler = (state, actions) => {
    return event => {
      const {
        target
      } = event;
      if (target === state.previousButton) {
        actions.previous();
      } else if (target === state.nextButton) {
        actions.next();
      } else if (target === state.closeButton || state.config.docClose && !state.isDraggingY && !state.isDraggingX && target.classList.contains('parvus__slide')) {
        actions.close();
      }
      event.stopPropagation();
    };
  };

  /**
   * Gesture Handler Module
   *
   * Handles gestures like pinch-to-zoom and swipe
   */

  /**
   * Reset image zoom
   *
   * @param {Object} state - The application state
   * @param {HTMLImageElement} currentImg - The image
   * @returns {void}
   */
  const resetZoom = (state, currentImg) => {
    currentImg.style.transition = 'transform 0.3s ease';
    currentImg.style.transform = '';
    setTimeout(() => {
      currentImg.style.transition = '';
      currentImg.style.transformOrigin = '';
    }, 300);
    state.resetZoomState();
    state.lightbox.classList.remove('parvus--is-zooming');
  };

  /**
   * Pinch zoom gesture
   *
   * @param {Object} state - The application state
   * @param {HTMLImageElement} currentImg - The image to zoom
   * @returns {void}
   */
  const pinchZoom = (state, currentImg) => {
    // Determine current finger positions
    const POINTS = Array.from(state.activePointers.values());

    // Calculate current distance between fingers
    const CURRENT_DISTANCE = Math.hypot(POINTS[1].clientX - POINTS[0].clientX, POINTS[1].clientY - POINTS[0].clientY);

    // Calculate the midpoint between the two points
    const MIDPOINT_X = (POINTS[0].clientX + POINTS[1].clientX) / 2;
    const MIDPOINT_Y = (POINTS[0].clientY + POINTS[1].clientY) / 2;

    // Convert midpoint to relative position within the image
    const IMG_RECT = currentImg.getBoundingClientRect();
    const RELATIVE_X = (MIDPOINT_X - IMG_RECT.left) / IMG_RECT.width;
    const RELATIVE_Y = (MIDPOINT_Y - IMG_RECT.top) / IMG_RECT.height;

    // When pinch gesture is about to start or the finger IDs have changed
    // Use a unique ID based on the pointer IDs to recognize changes
    const CURRENT_POINTERS_ID = POINTS.map(p => p.pointerId).sort().join('-');
    const IS_NEW_POINTER_COMBINATION = state.lastPointersId !== CURRENT_POINTERS_ID;
    if (!state.isPinching || IS_NEW_POINTER_COMBINATION) {
      state.isPinching = true;
      state.lastPointersId = CURRENT_POINTERS_ID;

      // Save the start distance and current scaling as a basis
      state.pinchStartDistance = CURRENT_DISTANCE / state.currentScale;

      // Store initial pinch position for this gesture
      if (!currentImg.style.transformOrigin && state.currentScale === 1 || state.currentScale === 1 && IS_NEW_POINTER_COMBINATION) {
        // Set the transform origin to the pinch midpoint
        currentImg.style.transformOrigin = `${RELATIVE_X * 100}% ${RELATIVE_Y * 100}%`;
      }
      state.lightbox.classList.add('parvus--is-zooming');
    }

    // Calculate scaling factor based on distance change
    const SCALE_FACTOR = CURRENT_DISTANCE / state.pinchStartDistance;

    // Limit scaling to 1 - 3
    state.currentScale = Math.min(Math.max(1, SCALE_FACTOR), 3);
    currentImg.style.willChange = 'transform';
    currentImg.style.transform = `scale(${state.currentScale})`;
  };

  /**
   * Determine the swipe direction (horizontal or vertical)
   *
   * @param {Object} state - The application state
   * @returns {void}
   */
  const doSwipe = state => {
    const MOVEMENT_THRESHOLD = 1.5;
    const MAX_OPACITY_DISTANCE = 100;
    const DIRECTION_BIAS = 1.15;
    const {
      startX,
      endX,
      startY,
      endY
    } = state.drag;
    const MOVEMENT_X = startX - endX;
    const MOVEMENT_Y = endY - startY;
    const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X);
    const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y);
    const GROUP = state.GROUPS[state.activeGroup];
    const SLIDER = GROUP.slider;
    const TOTAL_SLIDES = GROUP.triggerElements.length;
    const handleHorizontalSwipe = (movementX, distance) => {
      const IS_FIRST_SLIDE = state.currentIndex === 0;
      const IS_LAST_SLIDE = state.currentIndex === TOTAL_SLIDES - 1;
      const IS_LEFT_SWIPE = movementX > 0;
      const IS_RIGHT_SWIPE = movementX < 0;
      if (IS_FIRST_SLIDE && IS_RIGHT_SWIPE || IS_LAST_SLIDE && IS_LEFT_SWIPE) {
        const DAMPING_FACTOR = 1 / (1 + Math.pow(distance / 100, 0.15));
        const REDUCED_MOVEMENT = movementX * DAMPING_FACTOR;
        SLIDER.style.transform = `
        translate3d(${state.offsetTmp - Math.round(REDUCED_MOVEMENT)}px, 0, 0)
      `;
      } else {
        SLIDER.style.transform = `
        translate3d(${state.offsetTmp - Math.round(movementX)}px, 0, 0)
      `;
      }
    };
    const handleVerticalSwipe = (movementY, distance) => {
      if (!state.isReducedMotion && distance <= 100) {
        const NEW_OVERLAY_OPACITY = Math.max(0, state.lightboxOverlayOpacity - distance / MAX_OPACITY_DISTANCE);
        state.lightboxOverlay.style.opacity = NEW_OVERLAY_OPACITY;
      }
      state.lightbox.classList.add('parvus--is-vertical-closing');
      SLIDER.style.transform = `
      translate3d(${state.offsetTmp}px, ${Math.round(movementY)}px, 0)
    `;
    };
    if (state.isDraggingX || state.isDraggingY) {
      if (state.isDraggingX) {
        handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE);
      } else if (state.isDraggingY) {
        handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE);
      }
      return;
    }

    // Direction detection based on the relative ratio of movements
    if (MOVEMENT_X_DISTANCE > MOVEMENT_THRESHOLD || MOVEMENT_Y_DISTANCE > MOVEMENT_THRESHOLD) {
      // Horizontal swipe if X-movement is stronger than Y-movement * DIRECTION_BIAS
      if (MOVEMENT_X_DISTANCE > MOVEMENT_Y_DISTANCE * DIRECTION_BIAS && TOTAL_SLIDES > 1) {
        state.isDraggingX = true;
        state.isDraggingY = false;
        handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE);
      } else if (MOVEMENT_Y_DISTANCE > MOVEMENT_X_DISTANCE * DIRECTION_BIAS && state.config.swipeClose) {
        // Vertical swipe if Y-movement is stronger than X-movement * DIRECTION_BIAS
        state.isDraggingX = false;
        state.isDraggingY = true;
        handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE);
      }
    }
  };

  /**
   * Recalculate drag/swipe event after pointerup
   *
   * @param {Object} state - The application state
   * @param {Object} actions - Navigation actions
   * @returns {void}
   */
  const updateAfterDrag = (state, actions) => {
    const {
      startX,
      startY,
      endX,
      endY
    } = state.drag;
    const MOVEMENT_X = endX - startX;
    const MOVEMENT_Y = endY - startY;
    const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X);
    const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y);
    const {
      triggerElements
    } = state.GROUPS[state.activeGroup];
    const TOTAL_TRIGGER_ELEMENTS = triggerElements.length;
    if (state.isDraggingX) {
      const IS_RIGHT_SWIPE = MOVEMENT_X > 0;
      if (MOVEMENT_X_DISTANCE >= state.config.threshold) {
        if (IS_RIGHT_SWIPE && state.currentIndex > 0) {
          actions.previous();
        } else if (!IS_RIGHT_SWIPE && state.currentIndex < TOTAL_TRIGGER_ELEMENTS - 1) {
          actions.next();
        }
      }
      actions.updateOffset();
    } else if (state.isDraggingY) {
      if (MOVEMENT_Y_DISTANCE >= state.config.threshold && state.config.swipeClose) {
        actions.close();
      } else {
        state.lightbox.classList.remove('parvus--is-vertical-closing');
        actions.updateOffset();
      }
      state.lightboxOverlay.style.opacity = '';
    } else {
      actions.updateOffset();
    }
  };

  /**
   * Image Handler Module
   *
   * Handles image loading, captions, and dimensions
   */

  /**
   * Add caption to the container element
   *
   * @param {Object} config - Configuration object
   * @param {HTMLElement} containerEl - The container element to which the caption will be added
   * @param {HTMLElement} imageEl - The image the caption is linked to
   * @param {HTMLElement} el - The trigger element associated with the caption
   * @param {Number} index - The index of the caption
   * @returns {void}
   */
  const addCaption = (config, containerEl, imageEl, el, index) => {
    const getCaptionData = triggerEl => {
      const {
        captionsAttribute,
        captionsSelector,
        captionsIdAttribute = 'data-caption-id'
      } = config;

      // Check for an ID reference on the trigger element
      // This allows the caption to be anywhere on the page
      const CAPTION_ID = triggerEl.getAttribute(captionsIdAttribute);
      if (CAPTION_ID) {
        const CAPTION_EL = document.getElementById(CAPTION_ID);
        if (CAPTION_EL) {
          return CAPTION_EL.innerHTML;
        }
      }

      // Check for a direct caption attribute on the trigger element
      const DIRECT_CAPTION = triggerEl.getAttribute(captionsAttribute);
      if (DIRECT_CAPTION) {
        return DIRECT_CAPTION;
      }

      // Query for a selector inside the trigger element
      if (captionsSelector !== 'self') {
        const CAPTION_EL = triggerEl.querySelector(captionsSelector);
        if (CAPTION_EL) {
          // Prefer a direct attribute on the found element, otherwise use its content
          return CAPTION_EL.getAttribute(captionsAttribute) || CAPTION_EL.innerHTML;
        }
      }
      return null;
    };
    const CAPTION_DATA = getCaptionData(el);
    if (CAPTION_DATA) {
      const CAPTION_CONTAINER = document.createElement('div');
      const CAPTION_ID = `parvus__caption-${index}`;
      CAPTION_CONTAINER.className = 'parvus__caption';
      CAPTION_CONTAINER.id = CAPTION_ID;
      CAPTION_CONTAINER.innerHTML = `<p>${CAPTION_DATA}</p>`;
      containerEl.appendChild(CAPTION_CONTAINER);
      imageEl.setAttribute('aria-describedby', CAPTION_ID);
    }
  };

  /**
   * Add copyright to the image container element
   *
   * @param {Object} config - Configuration object
   * @param {HTMLElement} imageContainer - The image container element (parvus__content) to which the copyright will be added
   * @param {HTMLElement} imageEl - The image the copyright is linked to
   * @param {HTMLElement} el - The trigger element associated with the copyright
   * @param {Number} index - The index of the copyright
   * @returns {void}
   */
  const addCopyright = (config, imageContainer, imageEl, el, index) => {
    const getCopyrightData = triggerEl => {
      const {
        copyrightAttribute,
        copyrightSelector,
        copyrightIdAttribute = 'data-copyright-id'
      } = config;

      // Check for an ID reference on the trigger element
      // This allows the copyright to be anywhere on the page
      const COPYRIGHT_ID = triggerEl.getAttribute(copyrightIdAttribute);
      if (COPYRIGHT_ID) {
        const COPYRIGHT_EL = document.getElementById(COPYRIGHT_ID);
        if (COPYRIGHT_EL) {
          return COPYRIGHT_EL.innerHTML;
        }
      }

      // Check for a direct copyright attribute on the trigger element
      const DIRECT_COPYRIGHT = triggerEl.getAttribute(copyrightAttribute);
      if (DIRECT_COPYRIGHT) {
        return DIRECT_COPYRIGHT;
      }

      // Query for a selector inside the trigger element
      if (copyrightSelector !== 'self') {
        const COPYRIGHT_EL = triggerEl.querySelector(copyrightSelector);
        if (COPYRIGHT_EL) {
          // Prefer a direct attribute on the found element, otherwise use its content
          return COPYRIGHT_EL.getAttribute(copyrightAttribute) || COPYRIGHT_EL.innerHTML;
        }
      }
      return null;
    };
    const COPYRIGHT_DATA = getCopyrightData(el);
    if (COPYRIGHT_DATA) {
      const COPYRIGHT_CONTAINER = document.createElement('div');
      const COPYRIGHT_ID = `parvus__copyright-${index}`;
      COPYRIGHT_CONTAINER.className = 'parvus__copyright';
      COPYRIGHT_CONTAINER.id = COPYRIGHT_ID;
      COPYRIGHT_CONTAINER.innerHTML = `<small>${COPYRIGHT_DATA}</small>`;
      imageContainer.appendChild(COPYRIGHT_CONTAINER);

      // If image already has aria-describedby (from caption), append copyright ID
      const existingAriaDescribedby = imageEl.getAttribute('aria-describedby');
      if (existingAriaDescribedby) {
        imageEl.setAttribute('aria-describedby', `${existingAriaDescribedby} ${COPYRIGHT_ID}`);
      } else {
        imageEl.setAttribute('aria-describedby', COPYRIGHT_ID);
      }
    }
  };

  /**
   * Create image
   *
   * @param {Object} state - The application state
   * @param {HTMLElement} el - The trigger element
   * @param {Number} index - The index
   * @param {Function} callback - Callback function
   * @returns {void}
   */
  const createImage = (state, el, index, callback) => {
    const {
      contentElements,
      sliderElements
    } = state.GROUPS[state.activeGroup];
    if (contentElements[index] !== undefined) {
      if (callback && typeof callback === 'function') {
        callback();
      }
      return;
    }
    const CONTENT_CONTAINER_EL = sliderElements[index].querySelector('div');
    const IMAGE = new Image();
    const IMAGE_CONTAINER = document.createElement('div');
    const THUMBNAIL = el.querySelector('img');
    const LOADING_INDICATOR = document.createElement('div');
    IMAGE_CONTAINER.className = 'parvus__content';

    // Create loading indicator
    LOADING_INDICATOR.className = 'parvus__loader';
    LOADING_INDICATOR.setAttribute('role', 'progressbar');
    LOADING_INDICATOR.setAttribute('aria-label', state.config.l10n.lightboxLoadingIndicatorLabel);

    // Add loading indicator to content container
    CONTENT_CONTAINER_EL.appendChild(LOADING_INDICATOR);
    const checkImagePromise = new Promise((resolve, reject) => {
      IMAGE.onload = () => resolve(IMAGE);
      IMAGE.onerror = error => reject(error);
    });
    checkImagePromise.then(loadedImage => {
      loadedImage.style.opacity = 0;
      IMAGE_CONTAINER.appendChild(loadedImage);

      // Add copyright if available (inside IMAGE_CONTAINER)
      if (state.config.copyright) {
        addCopyright(state.config, IMAGE_CONTAINER, IMAGE, el, index);
      }
      CONTENT_CONTAINER_EL.appendChild(IMAGE_CONTAINER);

      // Add caption if available
      if (state.config.captions) {
        addCaption(state.config, CONTENT_CONTAINER_EL, IMAGE, el, index);
      }
      contentElements[index] = loadedImage;

      // Set image width and height
      loadedImage.setAttribute('width', loadedImage.naturalWidth);
      loadedImage.setAttribute('height', loadedImage.naturalHeight);

      // Set image dimension
      setImageDimension(sliderElements[index], loadedImage);
    }).catch(() => {
      const ERROR_CONTAINER = document.createElement('div');
      ERROR_CONTAINER.classList.add('parvus__content');
      ERROR_CONTAINER.classList.add('parvus__content--error');
      ERROR_CONTAINER.textContent = state.config.l10n.lightboxLoadingError;
      CONTENT_CONTAINER_EL.appendChild(ERROR_CONTAINER);
      contentElements[index] = ERROR_CONTAINER;
    }).finally(() => {
      CONTENT_CONTAINER_EL.removeChild(LOADING_INDICATOR);
      if (callback && typeof callback === 'function') {
        callback();
      }
    });

    // Add `sizes` attribute
    if (el.hasAttribute('data-sizes') && el.getAttribute('data-sizes') !== '') {
      IMAGE.setAttribute('sizes', el.getAttribute('data-sizes'));
    }

    // Add `srcset` attribute
    if (el.hasAttribute('data-srcset') && el.getAttribute('data-srcset') !== '') {
      IMAGE.setAttribute('srcset', el.getAttribute('data-srcset'));
    }

    // Add `src` attribute
    if (el.tagName === 'A') {
      IMAGE.setAttribute('src', el.href);
    } else {
      IMAGE.setAttribute('src', el.getAttribute('data-target'));
    }

    // `alt` attribute
    if (THUMBNAIL && THUMBNAIL.hasAttribute('alt') && THUMBNAIL.getAttribute('alt') !== '') {
      IMAGE.alt = THUMBNAIL.alt;
    } else if (el.hasAttribute('data-alt') && el.getAttribute('data-alt') !== '') {
      IMAGE.alt = el.getAttribute('data-alt');
    } else {
      IMAGE.alt = '';
    }
  };

  /**
   * Load Image
   *
   * @param {Object} state - The application state
   * @param {Number} index - The index of the image to load
   * @param {Boolean} animate - Whether to animate the image
   * @returns {void}
   */
  const loadImage = (state, index, animate) => {
    const IMAGE = state.GROUPS[state.activeGroup].contentElements[index];
    if (IMAGE && IMAGE.tagName === 'IMG') {
      const THUMBNAIL = state.GROUPS[state.activeGroup].triggerElements[index];
      if (animate && document.startViewTransition) {
        THUMBNAIL.style.viewTransitionName = 'lightboximage';
        const transition = document.startViewTransition(() => {
          IMAGE.style.opacity = '';
          THUMBNAIL.style.viewTransitionName = null;
          IMAGE.style.viewTransitionName = 'lightboximage';
        });
        transition.finished.finally(() => {
          IMAGE.style.viewTransitionName = null;
        });
      } else {
        IMAGE.style.opacity = '';
      }
    } else {
      IMAGE.style.opacity = '';
    }
  };

  /**
   * Set image dimension
   *
   * @param {HTMLElement} slideEl - The slide element
   * @param {HTMLElement} contentEl - The content element
   * @returns {void}
   */
  const setImageDimension = (slideEl, contentEl) => {
    if (contentEl.tagName !== 'IMG') {
      return;
    }
    const SRC_HEIGHT = contentEl.getAttribute('height');
    const SRC_WIDTH = contentEl.getAttribute('width');
    if (!SRC_HEIGHT || !SRC_WIDTH) {
      return;
    }
    const SLIDE_EL_STYLES = getComputedStyle(slideEl);
    const HORIZONTAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingLeft) + parseFloat(SLIDE_EL_STYLES.paddingRight);
    const VERTICAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingTop) + parseFloat(SLIDE_EL_STYLES.paddingBottom);
    const CAPTION_EL = slideEl.querySelector('.parvus__caption');
    const CAPTION_HEIGHT = CAPTION_EL ? CAPTION_EL.getBoundingClientRect().height : 0;
    const MAX_WIDTH = slideEl.offsetWidth - HORIZONTAL_PADDING;
    const MAX_HEIGHT = slideEl.offsetHeight - VERTICAL_PADDING - CAPTION_HEIGHT;
    const RATIO = Math.min(MAX_WIDTH / SRC_WIDTH || 0, MAX_HEIGHT / SRC_HEIGHT || 0);
    const NEW_WIDTH = SRC_WIDTH * RATIO;
    const NEW_HEIGHT = SRC_HEIGHT * RATIO;
    const USE_ORIGINAL_SIZE = SRC_WIDTH <= MAX_WIDTH && SRC_HEIGHT <= MAX_HEIGHT;
    contentEl.style.width = USE_ORIGINAL_SIZE ? '' : `${NEW_WIDTH}px`;
    contentEl.style.height = USE_ORIGINAL_SIZE ? '' : `${NEW_HEIGHT}px`;
  };

  /**
   * Create resize handler
   *
   * @param {Object} state - The application state
   * @param {Function} updateOffset - Update offset function
   * @returns {Function} Resize event handler
   */
  const createResizeHandler = (state, updateOffset) => {
    return () => {
      if (!state.resizeTicking) {
        state.resizeTicking = true;
        window.requestAnimationFrame(() => {
          state.GROUPS[state.activeGroup].sliderElements.forEach((slide, index) => {
            setImageDimension(slide, state.GROUPS[state.activeGroup].contentElements[index]);
          });
          updateOffset();
          state.resizeTicking = false;
        });
      }
    };
  };

  // Helper modules

  /**
   * Parvus Lightbox
   *
   * @param {Object} userOptions - User configuration options
   * @returns {Object} Parvus instance
   */
  function Parvus(userOptions) {
    const BROWSER_WINDOW = window;
    const STATE = new ParvusState();
    const MOTIONQUERY = BROWSER_WINDOW.matchMedia('(prefers-reduced-motion)');
    const PLUGIN_MANAGER = new PluginManager();

    // Event handlers will be created after actions are defined
    let keydownHandler, clickHandler, pointerdownHandler, pointermoveHandler, pointerupHandler, resizeHandler;

    /**
     * Click event handler to trigger Parvus
     *
     * @param {Event} event - The click event object
     */
    const triggerParvus = function triggerParvus(event) {
      event.preventDefault();
      open(this);
    };

    /**
     * Add an element
     *
     * @param {HTMLElement} el - The element to be added
     */
    const add = el => {
      // Check element type and attributes
      const IS_VALID_LINK = el.tagName === 'A' && el.hasAttribute('href');
      const IS_VALID_BUTTON = el.tagName === 'BUTTON' && el.hasAttribute('data-target');
      if (!IS_VALID_LINK && !IS_VALID_BUTTON) {
        throw new Error('Use a link with the \'href\' attribute or a button with the \'data-target\' attribute. Both attributes must contain a path to the image file.');
      }

      // Check if the lightbox already exists
      if (!STATE.lightbox) {
        createLightbox(STATE);

        // Execute afterInit hook when lightbox is first created
        PLUGIN_MANAGER.executeHook('afterInit', {
          state: STATE
        });
      }
      STATE.newGroup = getGroup(STATE, el);
      if (!STATE.GROUPS[STATE.newGroup]) {
        STATE.GROUPS[STATE.newGroup] = structuredClone(STATE.GROUP_ATTRIBUTES);
      }
      if (STATE.GROUPS[STATE.newGroup].triggerElements.includes(el)) {
        throw new Error('Ups, element already added.');
      }
      STATE.GROUPS[STATE.newGroup].triggerElements.push(el);
      if (STATE.config.zoomIndicator) {
        addZoomIndicator(el, STATE.config);
      }
      el.classList.add('parvus-trigger');
      el.addEventListener('click', triggerParvus);
      if (isOpen() && STATE.newGroup === STATE.activeGroup) {
        const EL_INDEX = STATE.GROUPS[STATE.newGroup].triggerElements.indexOf(el);
        createSlide(STATE, EL_INDEX);
        createImage(STATE, el, EL_INDEX, () => {
          loadImage(STATE, EL_INDEX);
        });
        updateAttributes(STATE);
        updateSliderNavigationStatus(STATE);
        updateCounter(STATE);
      }
    };

    /**
     * Remove an element
     *
     * @param {HTMLElement} el - The element to be removed
     */
    const remove = el => {
      if (!el || !el.hasAttribute('data-group')) {
        return;
      }
      const EL_GROUP = getGroup(STATE, el);
      const GROUP = STATE.GROUPS[EL_GROUP];

      // Check if element exists
      if (!GROUP) {
        return;
      }
      const EL_INDEX = GROUP.triggerElements.indexOf(el);
      if (EL_INDEX === -1) {
        return;
      }
      const IS_CURRENT_EL = isOpen() && EL_GROUP === STATE.activeGroup && EL_INDEX === STATE.currentIndex;

      // Remove group data
      if (GROUP.contentElements[EL_INDEX]) {
        const content = GROUP.contentElements[EL_INDEX];
        if (content.tagName === 'IMG') {
          content.src = '';
          content.srcset = '';
        }
      }

      // Remove DOM element
      const sliderElement = GROUP.sliderElements[EL_INDEX];
      if (sliderElement && sliderElement.parentNode) {
        sliderElement.parentNode.removeChild(sliderElement);
      }

      // Remove all array elements
      GROUP.triggerElements.splice(EL_INDEX, 1);
      GROUP.sliderElements.splice(EL_INDEX, 1);
      GROUP.contentElements.splice(EL_INDEX, 1);
      if (STATE.config.zoomIndicator) {
        removeZoomIndicator(el);
      }
      if (isOpen() && EL_GROUP === STATE.activeGroup) {
        if (IS_CURRENT_EL) {
          if (GROUP.triggerElements.length === 0) {
            close();
          } else if (STATE.currentIndex >= GROUP.triggerElements.length) {
            select(GROUP.triggerElements.length - 1);
          } else {
            updateAttributes(STATE);
            updateSliderNavigationStatus(STATE);
            updateCounter(STATE);
          }
        } else if (EL_INDEX < STATE.currentIndex) {
          STATE.currentIndex--;
          updateAttributes(STATE);
          updateSliderNavigationStatus(STATE);
          updateCounter(STATE);
        } else {
          updateAttributes(STATE);
          updateSliderNavigationStatus(STATE);
          updateCounter(STATE);
        }
      }

      // Unbind click event handler
      el.removeEventListener('click', triggerParvus);
      el.classList.remove('parvus-trigger');
    };

    /**
     * Open Parvus
     *
     * @param {HTMLElement} el
     */
    const open = el => {
      if (!STATE.lightbox || !el || !el.classList.contains('parvus-trigger') || isOpen()) {
        return;
      }
      STATE.activeGroup = getGroup(STATE, el);
      const GROUP = STATE.GROUPS[STATE.activeGroup];
      const EL_INDEX = GROUP.triggerElements.indexOf(el);
      if (EL_INDEX === -1) {
        throw new Error('Ups, element not found in group.');
      }
      STATE.currentIndex = EL_INDEX;
      history.pushState({
        parvus: 'close'
      }, 'Image', window.location.href);
      bindEvents();
      if (STATE.config.hideScrollbar) {
        document.body.style.marginInlineEnd = `${getScrollbarWidth()}px`;
        document.body.style.overflow = 'hidden';
      }
      STATE.lightbox.classList.add('parvus--is-opening');
      STATE.lightbox.showModal();
      createSlider(STATE);
      createSlide(STATE, STATE.currentIndex);
      updateOffset(STATE);
      updateAttributes(STATE);
      updateSliderNavigationStatus(STATE);
      updateCounter(STATE);
      loadSlide(STATE, STATE.currentIndex);
      createImage(STATE, el, STATE.currentIndex, () => {
        loadImage(STATE, STATE.currentIndex, true);
        STATE.lightbox.classList.remove('parvus--is-opening');
        GROUP.slider.classList.add('parvus__slider--animate');
      });
      preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex + 1);
      preload(STATE, createSlide, createImage, loadImage, STATE.currentIndex - 1);

      // Execute afterOpen hook
      PLUGIN_MANAGER.executeHook('afterOpen', {
        element: el,
        state: STATE
      });

      // Create and dispatch a new event
      dispatchCustomEvent(STATE.lightbox, 'open');
    };

    /**
     * Close Parvus
     */
    const close = () => {
      if (!isOpen()) {
        return;
      }
      const IMAGE = STATE.GROUPS[STATE.activeGroup].contentElements[STATE.currentIndex];
      const THUMBNAIL = STATE.GROUPS[STATE.activeGroup].triggerElements[STATE.currentIndex];
      unbindEvents();
      STATE.clearDrag();
      if (history.state?.parvus === 'close') {
        history.back();
      }
      STATE.lightbox.classList.add('parvus--is-closing');
      const transitionendHandler = () => {
        // Reset the image zoom (if ESC was pressed or went back in the browser history)
        // after the ViewTransition (otherwise it looks bad)
        if (STATE.isPinching) {
          resetZoom(STATE, IMAGE);
        }
        leaveSlide(STATE, STATE.currentIndex);
        STATE.lightbox.close();
        STATE.lightbox.classList.remove('parvus--is-closing');
        STATE.lightbox.classList.remove('parvus--is-vertical-closing');
        STATE.GROUPS[STATE.activeGroup].slider.remove();
        STATE.GROUPS[STATE.activeGroup].slider = null;
        STATE.GROUPS[STATE.activeGroup].sliderElements = [];
        STATE.GROUPS[STATE.activeGroup].contentElements = [];
        STATE.counter.removeAttribute('aria-hidden');
        STATE.previousButton.removeAttribute('aria-hidden');
        STATE.previousButton.removeAttribute('aria-disabled');
        STATE.nextButton.removeAttribute('aria-hidden');
        STATE.nextButton.removeAttribute('aria-disabled');
        if (STATE.config.hideScrollbar) {
          document.body.style.marginInlineEnd = '';
          document.body.style.overflow = '';
        }

        // Execute afterClose hook
        PLUGIN_MANAGER.executeHook('afterClose', {
          state: STATE
        });
      };
      if (IMAGE && IMAGE.tagName === 'IMG') {
        if (document.startViewTransition) {
          IMAGE.style.viewTransitionName = 'lightboximage';
          const transition = document.startViewTransition(() => {
            IMAGE.style.opacity = '0';
            IMAGE.style.viewTransitionName = null;
            THUMBNAIL.style.viewTransitionName = 'lightboximage';
          });
          transition.finished.finally(() => {
            transitionendHandler();
            THUMBNAIL.style.viewTransitionName = null;
          });
        } else {
          IMAGE.style.opacity = '0';
          requestAnimationFrame(transitionendHandler);
        }
      } else {
        transitionendHandler();
      }
    };

    /**
     * Select a specific slide by index
     *
     * @param {number} index - Index of the slide to select
     */
    const select = index => {
      if (!isOpen()) {
        throw new Error("Oops, I'm closed.");
      }
      if (typeof index !== 'number' || isNaN(index)) {
        throw new Error('Oops, no slide specified.');
      }
      const GROUP = STATE.GROUPS[STATE.activeGroup];
      const triggerElements = GROUP.triggerElements;
      if (index === STATE.currentIndex) {
        throw new Error(`Oops, slide ${index} is already selected.`);
      }
      if (index < 0 || index >= triggerElements.length) {
        throw new Error(`Oops, I can't find slide ${index}.`);
      }
      const OLD_INDEX = STATE.currentIndex;
      STATE.currentIndex = index;
      if (GROUP.sliderElements[index]) {
        loadSlide(STATE, index);
      } else {
        createSlide(STATE, index);
        createImage(STATE, GROUP.triggerElements[index], index, () => {
          loadImage(STATE, index);
        });
        loadSlide(STATE, index);
      }
      updateOffset(STATE);
      updateSliderNavigationStatus(STATE);
      updateCounter(STATE);

      // Execute slideChange hook
      PLUGIN_MANAGER.executeHook('slideChange', {
        index,
        oldIndex: OLD_INDEX,
        state: STATE
      });
      if (index < OLD_INDEX) {
        preload(STATE, createSlide, createImage, loadImage, index - 1);
      } else {
        preload(STATE, createSlide, createImage, loadImage, index + 1);
      }
      leaveSlide(STATE, OLD_INDEX);

      // Create and dispatch a new event
      dispatchCustomEvent(STATE.lightbox, 'select');
    };

    /**
     * Select the previous slide
     */
    const previous = () => {
      if (STATE.currentIndex > 0) {
        select(STATE.currentIndex - 1);
      }
    };

    /**
     * Select the next slide
     */
    const next = () => {
      const {
        triggerElements
      } = STATE.GROUPS[STATE.activeGroup];
      if (STATE.currentIndex < triggerElements.length - 1) {
        select(STATE.currentIndex + 1);
      }
    };

    /**
     * Bind specified events
     */
    const bindEvents = () => {
      const actions = {
        close,
        previous,
        next,
        updateOffset: () => updateOffset(STATE)
      };

      // Create handlers with state and actions
      keydownHandler = createKeydownHandler(STATE, actions);
      clickHandler = createClickHandler(STATE, actions);
      resizeHandler = createResizeHandler(STATE, () => updateOffset(STATE));
      const updateAfterDragHandler = () => updateAfterDrag(STATE, actions);
      const pinchZoomHandler = img => pinchZoom(STATE, img);
      const doSwipeHandler = () => doSwipe(STATE);
      const resetZoomHandler = img => resetZoom(STATE, img);
      pointerdownHandler = createPointerdownHandler(STATE);
      pointermoveHandler = createPointermoveHandler(STATE, pinchZoomHandler, doSwipeHandler);
      pointerupHandler = createPointerupHandler(STATE, resetZoomHandler, updateAfterDragHandler);
      BROWSER_WINDOW.addEventListener('keydown', keydownHandler);
      BROWSER_WINDOW.addEventListener('resize', resizeHandler);

      // Popstate event
      BROWSER_WINDOW.addEventListener('popstate', close);

      // Check for any OS level changes to the prefers reduced motion preference
      MOTIONQUERY.addEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY));

      // Click event
      STATE.lightbox.addEventListener('click', clickHandler);

      // Pointer events
      STATE.lightbox.addEventListener('pointerdown', pointerdownHandler, {
        passive: false
      });
      STATE.lightbox.addEventListener('pointerup', pointerupHandler, {
        passive: true
      });
      STATE.lightbox.addEventListener('pointermove', pointermoveHandler, {
        passive: false
      });
    };

    /**
     * Unbind specified events
     */
    const unbindEvents = () => {
      BROWSER_WINDOW.removeEventListener('keydown', keydownHandler);
      BROWSER_WINDOW.removeEventListener('resize', resizeHandler);

      // Popstate event
      BROWSER_WINDOW.removeEventListener('popstate', close);

      // Check for any OS level changes to the prefers reduced motion preference
      MOTIONQUERY.removeEventListener('change', () => reducedMotionCheck(STATE, MOTIONQUERY));

      // Click event
      STATE.lightbox.removeEventListener('click', clickHandler);

      // Pointer events
      STATE.lightbox.removeEventListener('pointerdown', pointerdownHandler);
      STATE.lightbox.removeEventListener('pointerup', pointerupHandler);
      STATE.lightbox.removeEventListener('pointermove', pointermoveHandler);
    };

    /**
     * Destroy Parvus
     */
    const destroy = () => {
      if (!STATE.lightbox) {
        return;
      }
      if (isOpen()) {
        close();
      }

      // Add setTimeout to ensure all possible close transitions are completed
      setTimeout(() => {
        unbindEvents();

        // Remove all registered event listeners for custom events
        const eventTypes = ['open', 'close', 'select', 'destroy'];
        eventTypes.forEach(eventType => {
          const listeners = STATE.lightbox._listeners?.[eventType] || [];
          listeners.forEach(listener => {
            STATE.lightbox.removeEventListener(eventType, listener);
          });
        });

        // Remove event listeners from trigger elements
        const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll('.parvus-trigger');
        LIGHTBOX_TRIGGER_ELS.forEach(el => {
          el.removeEventListener('click', triggerParvus);
          el.classList.remove('parvus-trigger');
          if (STATE.config.zoomIndicator) {
            removeZoomIndicator(el);
          }
          if (el.dataset.group) {
            delete el.dataset.group;
          }
        });

        // Create and dispatch a new event
        dispatchCustomEvent(STATE.lightbox, 'destroy');
        STATE.lightbox.remove();

        // Remove references
        STATE.lightbox = null;
        STATE.lightboxOverlay = null;
        STATE.toolbar = null;
        STATE.toolbarLeft = null;
        STATE.toolbarRight = null;
        STATE.controls = null;
        STATE.previousButton = null;
        STATE.nextButton = null;
        STATE.closeButton = null;
        STATE.counter = null;

        // Remove group data
        Object.keys(STATE.GROUPS).forEach(groupKey => {
          const group = STATE.GROUPS[groupKey];
          if (group && group.contentElements) {
            group.contentElements.forEach(content => {
              if (content && content.tagName === 'IMG') {
                content.src = '';
                content.srcset = '';
              }
            });
          }
          delete STATE.GROUPS[groupKey];
        });

        // Reset variables
        STATE.groupIdCounter = 0;
        STATE.newGroup = null;
        STATE.activeGroup = null;
        STATE.currentIndex = 0;
      }, 1000);
    };

    /**
     * Check if Parvus is open
     *
     * @returns {boolean} - True if Parvus is open, otherwise false
     */
    const isOpen = () => {
      return STATE.lightbox?.hasAttribute('open');
    };

    /**
     * Get the current index
     *
     * @returns {number} - The current index
     */
    const getCurrentIndex = () => {
      return STATE.currentIndex;
    };

    /**
     * Bind a specific event listener
     *
     * @param {String} eventName - The name of the event to bind
     * @param {Function} callback - The callback function
     */
    const on$1 = (eventName, callback) => {
      on(STATE.lightbox, eventName, callback);
    };

    /**
     * Unbind a specific event listener
     *
     * @param {String} eventName - The name of the event to unbind
     * @param {Function} callback - The callback function
     */
    const off$1 = (eventName, callback) => {
      off(STATE.lightbox, eventName, callback);
    };

    /**
     * Use a plugin
     *
     * @param {Object} plugin - Plugin object
     * @param {Object} options - Plugin options
     */
    const use = (plugin, options = {}) => {
      PLUGIN_MANAGER.register(plugin, options);
    };

    /**
     * Add a hook callback
     *
     * @param {String} hookName - Hook name
     * @param {Function} callback - Callback function
     */
    const addHook = (hookName, callback) => {
      PLUGIN_MANAGER.addHook(hookName, callback);
    };

    /**
     * Get registered plugins
     *
     * @returns {Array} Array of plugin names
     */
    const getPlugins = () => {
      return PLUGIN_MANAGER.getPlugins();
    };

    /**
     * Init
     */
    const init = () => {
      // Merge user options into defaults
      STATE.config = mergeOptions(userOptions);
      reducedMotionCheck(STATE, MOTIONQUERY);

      // Install plugins with context
      const pluginContext = {
        state: STATE,
        on: on,
        addHook: PLUGIN_MANAGER.addHook.bind(PLUGIN_MANAGER),
        config: STATE.config
      };
      PLUGIN_MANAGER.install(pluginContext);
      if (STATE.config.gallerySelector !== null) {
        // Get a list of all `gallerySelector` elements within the document
        const GALLERY_ELS = document.querySelectorAll(STATE.config.gallerySelector);

        // Execute a few things once per element
        GALLERY_ELS.forEach((galleryEl, index) => {
          const GALLERY_INDEX = index;
          // Get a list of all `selector` elements within the `gallerySelector`
          const LIGHTBOX_TRIGGER_GALLERY_ELS = galleryEl.querySelectorAll(STATE.config.selector);

          // Execute a few things once per element
          LIGHTBOX_TRIGGER_GALLERY_ELS.forEach(lightboxTriggerEl => {
            lightboxTriggerEl.setAttribute('data-group', `parvus-gallery-${GALLERY_INDEX}`);
            add(lightboxTriggerEl);
          });
        });
      }

      // Get a list of all `selector` elements outside or without the `gallerySelector`
      const LIGHTBOX_TRIGGER_ELS = document.querySelectorAll(`${STATE.config.selector}:not(.parvus-trigger)`);
      LIGHTBOX_TRIGGER_ELS.forEach(add);
    };
    init();
    return {
      init,
      open,
      close,
      select,
      previous,
      next,
      currentIndex: getCurrentIndex,
      add,
      remove,
      destroy,
      isOpen,
      on: on$1,
      off: off$1,
      use,
      addHook,
      getPlugins
    };
  }

  return Parvus;

}));


================================================
FILE: package.json
================================================
{
  "name": "parvus",
  "type": "module",
  "version": "3.1.0",
  "description": "An open source, dependency free image lightbox with the goal of being accessible.",
  "main": "./dist/js/parvus.js",
  "module": "./dist/js/parvus.esm.js",
  "style": "./dist/css/parvus.css",
  "devDependencies": {
    "@babel/core": "^7.29.0",
    "@babel/preset-env": "^7.29.2",
    "@rollup/plugin-babel": "^7.0.0",
    "@rollup/plugin-commonjs": "^29.0.2",
    "@rollup/plugin-node-resolve": "^16.0.3",
    "@rollup/plugin-terser": "^1.0.0",
    "core-js": "^3.49.0",
    "postcss": "^8.5.10",
    "rollup": "^4.60.2",
    "rollup-plugin-license": "^3.7.1",
    "rollup-plugin-postcss": "^4.0.2",
    "sass": "^1.99.0",
    "standard": "^17.1.2",
    "stylelint": "^17.8.0",
    "stylelint-config-standard-scss": "^17.0.0",
    "stylelint-scss": "^7.0.0",
    "stylelint-use-logical": "^2.1.3"
  },
  "browserslist": [
    "last 2 versions and > 1% and not dead"
  ],
  "standard": {
    "globals": [
      "Image",
      "history",
      "CustomEvent",
      "requestAnimationFrame",
      "getComputedStyle"
    ]
  },
  "scripts": {
    "build": "npm run testCss && npm run buildCss && npm run testJs && npm run buildJs",
    "buildCss": "rollup -c --environment BUILDCSS --bundleConfigAsCjs",
    "buildJs": "rollup -c --environment BUILDJS --bundleConfigAsCjs",
    "buildWatch": "npm run buildWatchJs && npm run buildWatchCss",
    "buildWatchCss": "rollup -c -w --environment BUILDCSS --bundleConfigAsCjs",
    "buildWatchJs": "rollup -c -w --environment BUILDJS --bundleConfigAsCjs",
    "testCss": "stylelint \"src/scss/parvus.scss\"",
    "testJs": "standard \"src/js/parvus.js\"",
    "test": "npm run testCss && npm run testJs"
  },
  "exports": {
    ".": {
      "import": "./dist/js/parvus.esm.js",
      "require": "./dist/js/parvus.js"
    },
    "./src/scss/*": "./src/scss/*.scss",
    "./src/l10n/*": "./src/l10n/*.js"
  },
  "repository": {
    "type": "git",
    "url": "git://github.com/deoostfrees/parvus.git"
  },
  "keywords": [
    "lightbox",
    "accessible",
    "a11y",
    "javascript",
    "vanilla",
    "scss",
    "css"
  ],
  "author": "Benjamin de Oostfrees",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/deoostfrees/parvus/issues"
  },
  "homepage": "https://github.com/deoostfrees/parvus"
}


================================================
FILE: rollup.config.js
================================================
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import terser from '@rollup/plugin-terser'
import postcss from 'rollup-plugin-postcss'
import babel from '@rollup/plugin-babel'
import license from 'rollup-plugin-license'

import pkg from './package.json'

const bannerContent = `
  Parvus

  @author ${pkg.author}
  @version ${pkg.version}
  @url ${pkg.homepage}

  ${pkg.license} license`

const rollupBuilds = []

/**
 * Build JavaScript
 *
 */
if (process.env.BUILDJS) {
  rollupBuilds.push({
    input: './src/js/parvus.js',
    output: [
      {
        format: 'umd',
        file: './dist/js/parvus.js',
        name: 'Parvus'
      },
      {
        format: 'es',
        file: './dist/js/parvus.esm.js',
        name: 'Parvus'
      },
      {
        format: 'umd',
        file: './dist/js/parvus.min.js',
        name: 'Parvus',
        plugins: [
          terser(),
          license({
            banner: {
              content: bannerContent
            }
          })
        ]
      },
      {
        format: 'es',
        file: './dist/js/parvus.esm.min.js',
        name: 'Parvus',
        plugins: [
          terser(),
          license({
            banner: {
              content: bannerContent
            }
          })
        ]
      }
    ],
    plugins: [
      resolve({
        browser: true
      }),
      commonjs(),
      babel({
        babelHelpers: 'bundled',
        exclude: 'node_modules/**',
        presets: [
          ['@babel/preset-env', {
            corejs: 3.15,
            useBuiltIns: 'entry'
          }]
        ]
      }),
      license({
        banner: {
          content: bannerContent
        }
      })
    ],
    watch: {
      clearScreen: false
    }
  })
}

/**
 * Build CSS
 *
 */
if (process.env.BUILDCSS) {
  rollupBuilds.push(
    {
      input: './src/scss/parvus.scss',
      output: [
        {
          file: './dist/css/parvus.css'
        }
      ],
      plugins: [
        resolve({
          browser: true
        }),
        commonjs(),
        postcss({
          extract: true
        }),
        license({
          banner: {
            content: bannerContent
          }
        })
      ],
      watch: {
        clearScreen: false
      }
    },
    {
      input: './src/scss/parvus.scss',
      output: [
        {
          file: './dist/css/parvus.min.css'
        }
      ],
      plugins: [
        resolve({
          browser: true
        }),
        commonjs(),
        postcss({
          extract: true,
          minimize: true
        }),
        license({
          banner: {
            content: bannerContent
          }
        })
      ],
      watch: {
        clearScreen: false
      }
    }
  )
}

export default rollupBuilds


================================================
FILE: src/js/core/config.js
================================================
import en from '../../l10n/en.js'

/**
 * Default configuration options
 */
export const DEFAULT_OPTIONS = {
  selector: '.lightbox',
  gallerySelector: null,
  zoomIndicator: true,
  captions: true,
  captionsSelector: 'self',
  captionsAttribute: 'data-caption',
  copyright: true,
  copyrightSelector: 'self',
  copyrightAttribute: 'data-copyright',
  docClose: true,
  swipeClose: true,
  simulateTouch: true,
  threshold: 50,
  hideScrollbar: true,
  lightboxIndicatorIcon: '<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>',
  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>',
  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>',
  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>',
  l10n: en
}

/**
 * Merge default options with user-provided options
 *
 * @param {Object} userOptions - User-provided options
 * @returns {Object} - Merged options object
 */
export const mergeOptions = (userOptions) => {
  const MERGED_OPTIONS = {
    ...DEFAULT_OPTIONS,
    ...userOptions
  }

  if (userOptions && userOptions.l10n) {
    MERGED_OPTIONS.l10n = {
      ...DEFAULT_OPTIONS.l10n,
      ...userOptions.l10n
    }
  }

  return MERGED_OPTIONS
}


================================================
FILE: src/js/core/events.js
================================================
/**
 * Event System Module
 *
 * Handles custom event dispatching and listeners
 */

/**
 * Dispatch a custom event
 *
 * @param {HTMLElement} lightbox - The lightbox element
 * @param {String} type - The type of the event to dispatch
 * @returns {void}
 */
export const dispatchCustomEvent = (lightbox, type) => {
  const CUSTOM_EVENT = new CustomEvent(type, {
    cancelable: true
  })

  lightbox.dispatchEvent(CUSTOM_EVENT)
}

/**
 * Bind a specific event listener
 *
 * @param {HTMLElement} lightbox - The lightbox element
 * @param {String} eventName - The name of the event to bind
 * @param {Function} callback - The callback function
 * @returns {void}
 */
export const on = (lightbox, eventName, callback) => {
  if (lightbox) {
    lightbox.addEventListener(eventName, callback)
  }
}

/**
 * Unbind a specific event listener
 *
 * @param {HTMLElement} lightbox - The lightbox element
 * @param {String} eventName - The name of the event to unbind
 * @param {Function} callback - The callback function
 * @returns {void}
 */
export const off = (lightbox, eventName, callback) => {
  if (lightbox) {
    lightbox.removeEventListener(eventName, callback)
  }
}


================================================
FILE: src/js/core/navigation.js
================================================
/**
 * Navigation Module
 *
 * Handles slide navigation and transitions
 */

/**
 * Update offset
 *
 * @param {Object} state - The application state
 * @returns {void}
 */
export const updateOffset = (state) => {
  state.activeGroup = state.activeGroup !== null ? state.activeGroup : state.newGroup

  state.offset = -state.currentIndex * state.lightbox.offsetWidth

  state.GROUPS[state.activeGroup].slider.style.transform = `translate3d(${state.offset}px, 0, 0)`
  state.offsetTmp = state.offset
}

/**
 * Load slide with the specified index
 *
 * @param {Object} state - The application state
 * @param {Number} index - The index of the slide to be loaded
 * @returns {void}
 */
export const loadSlide = (state, index) => {
  state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'false')
}

/**
 * Leave slide
 *
 * @param {Object} state - The application state
 * @param {Number} index - The index of the slide to leave
 * @returns {void}
 */
export const leaveSlide = (state, index) => {
  if (state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
    state.GROUPS[state.activeGroup].sliderElements[index].setAttribute('aria-hidden', 'true')
  }
}

/**
 * Preload slide with the specified index
 *
 * @param {Object} state - The application state
 * @param {Function} createSlide - Create slide function
 * @param {Function} createImage - Create image function
 * @param {Function} loadImage - Load image function
 * @param {Number} index - The index of the slide to be preloaded
 * @returns {void}
 */
export const preload = (state, createSlide, createImage, loadImage, index) => {
  if (index < 0 || index >= state.GROUPS[state.activeGroup].triggerElements.length || state.GROUPS[state.activeGroup].sliderElements[index] !== undefined) {
    return
  }

  createSlide(state, index)
  createImage(state, state.GROUPS[state.activeGroup].triggerElements[index], index, () => {
    loadImage(state, index)
  })
}


================================================
FILE: src/js/core/plugins.js
================================================
/**
 * Plugin management for Parvus
 *
 * Provides a system for registering and managing plugins
 */

export class PluginManager {
  constructor () {
    this.plugins = []
    this.hooks = {}
    this.context = null
    this.isInitialized = false
  }

  /**
   * Register a plugin
   *
   * @param {Object} plugin - Plugin object with name and install function
   * @param {Object} options - Plugin-specific options
   */
  register (plugin, options = {}) {
    if (!plugin || typeof plugin.install !== 'function') {
      throw new Error('Plugin must have an install function')
    }

    if (!plugin.name) {
      throw new Error('Plugin must have a name')
    }

    // Check if plugin is already registered
    const existingPlugin = this.plugins.find(p => p.name === plugin.name)
    if (existingPlugin) {
      console.warn(`Plugin "${plugin.name}" is already registered`)
      return
    }

    this.plugins.push({ plugin, options })

    // If already initialized, install immediately
    if (this.isInitialized && this.context) {
      this.installPlugin(plugin, options)
    }
  }

  /**
   * Install a single plugin
   *
   * @param {Object} plugin - Plugin object
   * @param {Object} options - Plugin options
   */
  installPlugin (plugin, options) {
    try {
      plugin.install(this.context, options)

      // If lightbox already exists, execute afterInit hook for this plugin immediately
      if (this.context && this.context.state && this.context.state.lightbox) {
        this.executeHook('afterInit', { state: this.context.state })
      }
    } catch (error) {
      console.error(`Failed to install plugin "${plugin.name}":`, error)
    }
  }

  /**
   * Install all registered plugins
   *
   * @param {Object} context - Parvus instance context
   */
  install (context) {
    this.context = context
    this.isInitialized = true

    this.plugins.forEach(({ plugin, options }) => {
      this.installPlugin(plugin, options)
    })
  }

  /**
   * Execute a hook
   *
   * @param {String} hookName - Name of the hook
   * @param {*} data - Data to pass to hook callbacks
   */
  executeHook (hookName, data) {
    const callbacks = this.hooks[hookName] || []
    callbacks.forEach(callback => {
      try {
        callback(data)
      } catch (error) {
        console.error(`Error in hook "${hookName}":`, error)
      }
    })
  }

  /**
   * Register a hook callback
   *
   * @param {String} hookName - Name of the hook
   * @param {Function} callback - Callback function
   */
  addHook (hookName, callback) {
    if (!this.hooks[hookName]) {
      this.hooks[hookName] = []
    }
    this.hooks[hookName].push(callback)
  }

  /**
   * Remove a hook callback
   *
   * @param {String} hookName - Name of the hook
   * @param {Function} callback - Callback function to remove
   */
  removeHook (hookName, callback) {
    if (!this.hooks[hookName]) return

    this.hooks[hookName] = this.hooks[hookName].filter(cb => cb !== callback)
  }

  /**
   * Get all registered plugins
   *
   * @returns {Array} Array of plugin names
   */
  getPlugins () {
    return this.plugins.map(p => p.plugin.name)
  }
}


================================================
FILE: src/js/core/state.js
================================================
/**
 * State management for Parvus
 *
 * Centralizes all mutable state variables
 */
export class ParvusState {
  constructor () {
    // Group management
    this.GROUP_ATTRIBUTES = {
      triggerElements: [],
      slider: null,
      sliderElements: [],
      contentElements: []
    }
    this.GROUPS = {}
    this.groupIdCounter = 0
    this.newGroup = null
    this.activeGroup = null
    this.currentIndex = 0

    // Configuration
    this.config = {}

    // DOM elements
    this.lightbox = null
    this.lightboxOverlay = null
    this.lightboxOverlayOpacity = 1
    this.toolbar = null
    this.toolbarLeft = null
    this.toolbarRight = null
    this.controls = null
    this.previousButton = null
    this.nextButton = null
    this.closeButton = null
    this.counter = null

    // Drag & interaction state
    this.drag = {}
    this.isDraggingX = false
    this.isDraggingY = false
    this.pointerDown = false
    this.activePointers = new Map()

    // Zoom state
    this.currentScale = 1
    this.isPinching = false
    this.isTap = false
    this.pinchStartDistance = 0
    this.lastPointersId = null

    // Offset & animation
    this.offset = null
    this.offsetTmp = null
    this.resizeTicking = false
    this.isReducedMotion = true
  }

  /**
   * Clear drag state
   */
  clearDrag () {
    this.drag = {
      startX: 0,
      endX: 0,
      startY: 0,
      endY: 0
    }
  }

  /**
   * Get the active group
   *
   * @returns {Object} The active group
   */
  getActiveGroup () {
    return this.GROUPS[this.activeGroup]
  }

  /**
   * Reset zoom state
   */
  resetZoomState () {
    this.isPinching = false
    this.isTap = false
    this.currentScale = 1
    this.pinchStartDistance = 0
    this.lastPointersId = ''
  }
}


================================================
FILE: src/js/core/utils.js
================================================
/**
 * Utils Module
 *
 * Utility functions
 */

/**
 * Check prefers reduced motion
 *
 * @param {Object} state - The application state
 * @param {MediaQueryList} motionQuery - The media query list
 * @returns {void}
 */
export const reducedMotionCheck = (state, motionQuery) => {
  if (motionQuery.matches) {
    state.isReducedMotion = true
  } else {
    state.isReducedMotion = false
  }
}

/**
 * Retrieves or creates a group identifier for the given element
 *
 * @param {Object} state - The application state
 * @param {HTMLElement} el - DOM element to get or assign a group to
 * @returns {string} The group identifier associated with the element
 */
export const getGroup = (state, el) => {
  // Return existing group identifier if already assigned
  if (el.dataset.group) {
    return el.dataset.group
  }

  // Generate new unique group identifier using counter
  const EL_GROUP = `default-${state.groupIdCounter++}`

  // Assign the new group identifier to element's dataset
  el.dataset.group = EL_GROUP

  return EL_GROUP
}


================================================
FILE: src/js/handlers/gestures.js
================================================
/**
 * Gesture Handler Module
 *
 * Handles gestures like pinch-to-zoom and swipe
 */

/**
 * Reset image zoom
 *
 * @param {Object} state - The application state
 * @param {HTMLImageElement} currentImg - The image
 * @returns {void}
 */
export const resetZoom = (state, currentImg) => {
  currentImg.style.transition = 'transform 0.3s ease'
  currentImg.style.transform = ''

  setTimeout(() => {
    currentImg.style.transition = ''
    currentImg.style.transformOrigin = ''
  }, 300)

  state.resetZoomState()

  state.lightbox.classList.remove('parvus--is-zooming')
}

/**
 * Pinch zoom gesture
 *
 * @param {Object} state - The application state
 * @param {HTMLImageElement} currentImg - The image to zoom
 * @returns {void}
 */
export const pinchZoom = (state, currentImg) => {
  // Determine current finger positions
  const POINTS = Array.from(state.activePointers.values())

  // Calculate current distance between fingers
  const CURRENT_DISTANCE = Math.hypot(
    POINTS[1].clientX - POINTS[0].clientX,
    POINTS[1].clientY - POINTS[0].clientY
  )

  // Calculate the midpoint between the two points
  const MIDPOINT_X = (POINTS[0].clientX + POINTS[1].clientX) / 2
  const MIDPOINT_Y = (POINTS[0].clientY + POINTS[1].clientY) / 2

  // Convert midpoint to relative position within the image
  const IMG_RECT = currentImg.getBoundingClientRect()
  const RELATIVE_X = (MIDPOINT_X - IMG_RECT.left) / IMG_RECT.width
  const RELATIVE_Y = (MIDPOINT_Y - IMG_RECT.top) / IMG_RECT.height

  // When pinch gesture is about to start or the finger IDs have changed
  // Use a unique ID based on the pointer IDs to recognize changes
  const CURRENT_POINTERS_ID = POINTS.map(p => p.pointerId).sort().join('-')
  const IS_NEW_POINTER_COMBINATION = state.lastPointersId !== CURRENT_POINTERS_ID

  if (!state.isPinching || IS_NEW_POINTER_COMBINATION) {
    state.isPinching = true
    state.lastPointersId = CURRENT_POINTERS_ID

    // Save the start distance and current scaling as a basis
    state.pinchStartDistance = CURRENT_DISTANCE / state.currentScale

    // Store initial pinch position for this gesture
    if ((!currentImg.style.transformOrigin && state.currentScale === 1) ||
      (state.currentScale === 1 && IS_NEW_POINTER_COMBINATION)) {
      // Set the transform origin to the pinch midpoint
      currentImg.style.transformOrigin = `${RELATIVE_X * 100}% ${RELATIVE_Y * 100}%`
    }

    state.lightbox.classList.add('parvus--is-zooming')
  }

  // Calculate scaling factor based on distance change
  const SCALE_FACTOR = CURRENT_DISTANCE / state.pinchStartDistance

  // Limit scaling to 1 - 3
  state.currentScale = Math.min(Math.max(1, SCALE_FACTOR), 3)

  currentImg.style.willChange = 'transform'
  currentImg.style.transform = `scale(${state.currentScale})`
}

/**
 * Determine the swipe direction (horizontal or vertical)
 *
 * @param {Object} state - The application state
 * @returns {void}
 */
export const doSwipe = (state) => {
  const MOVEMENT_THRESHOLD = 1.5
  const MAX_OPACITY_DISTANCE = 100
  const DIRECTION_BIAS = 1.15

  const { startX, endX, startY, endY } = state.drag
  const MOVEMENT_X = startX - endX
  const MOVEMENT_Y = endY - startY
  const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X)
  const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y)

  const GROUP = state.GROUPS[state.activeGroup]
  const SLIDER = GROUP.slider
  const TOTAL_SLIDES = GROUP.triggerElements.length

  const handleHorizontalSwipe = (movementX, distance) => {
    const IS_FIRST_SLIDE = state.currentIndex === 0
    const IS_LAST_SLIDE = state.currentIndex === TOTAL_SLIDES - 1

    const IS_LEFT_SWIPE = movementX > 0
    const IS_RIGHT_SWIPE = movementX < 0

    if ((IS_FIRST_SLIDE && IS_RIGHT_SWIPE) || (IS_LAST_SLIDE && IS_LEFT_SWIPE)) {
      const DAMPING_FACTOR = 1 / (1 + Math.pow(distance / 100, 0.15))
      const REDUCED_MOVEMENT = movementX * DAMPING_FACTOR

      SLIDER.style.transform = `
        translate3d(${state.offsetTmp - Math.round(REDUCED_MOVEMENT)}px, 0, 0)
      `
    } else {
      SLIDER.style.transform = `
        translate3d(${state.offsetTmp - Math.round(movementX)}px, 0, 0)
      `
    }
  }

  const handleVerticalSwipe = (movementY, distance) => {
    if (!state.isReducedMotion && distance <= 100) {
      const NEW_OVERLAY_OPACITY = Math.max(0, state.lightboxOverlayOpacity - (distance / MAX_OPACITY_DISTANCE))

      state.lightboxOverlay.style.opacity = NEW_OVERLAY_OPACITY
    }

    state.lightbox.classList.add('parvus--is-vertical-closing')

    SLIDER.style.transform = `
      translate3d(${state.offsetTmp}px, ${Math.round(movementY)}px, 0)
    `
  }

  if (state.isDraggingX || state.isDraggingY) {
    if (state.isDraggingX) {
      handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE)
    } else if (state.isDraggingY) {
      handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE)
    }
    return
  }

  // Direction detection based on the relative ratio of movements
  if (MOVEMENT_X_DISTANCE > MOVEMENT_THRESHOLD || MOVEMENT_Y_DISTANCE > MOVEMENT_THRESHOLD) {
    // Horizontal swipe if X-movement is stronger than Y-movement * DIRECTION_BIAS
    if (MOVEMENT_X_DISTANCE > MOVEMENT_Y_DISTANCE * DIRECTION_BIAS && TOTAL_SLIDES > 1) {
      state.isDraggingX = true
      state.isDraggingY = false

      handleHorizontalSwipe(MOVEMENT_X, MOVEMENT_X_DISTANCE)
    } else if (MOVEMENT_Y_DISTANCE > MOVEMENT_X_DISTANCE * DIRECTION_BIAS && state.config.swipeClose) {
      // Vertical swipe if Y-movement is stronger than X-movement * DIRECTION_BIAS
      state.isDraggingX = false
      state.isDraggingY = true

      handleVerticalSwipe(MOVEMENT_Y, MOVEMENT_Y_DISTANCE)
    }
  }
}

/**
 * Recalculate drag/swipe event after pointerup
 *
 * @param {Object} state - The application state
 * @param {Object} actions - Navigation actions
 * @returns {void}
 */
export const updateAfterDrag = (state, actions) => {
  const { startX, startY, endX, endY } = state.drag
  const MOVEMENT_X = endX - startX
  const MOVEMENT_Y = endY - startY
  const MOVEMENT_X_DISTANCE = Math.abs(MOVEMENT_X)
  const MOVEMENT_Y_DISTANCE = Math.abs(MOVEMENT_Y)
  const { triggerElements } = state.GROUPS[state.activeGroup]
  const TOTAL_TRIGGER_ELEMENTS = triggerElements.length

  if (state.isDraggingX) {
    const IS_RIGHT_SWIPE = MOVEMENT_X > 0

    if (MOVEMENT_X_DISTANCE >= state.config.threshold) {
      if (IS_RIGHT_SWIPE && state.currentIndex > 0) {
        actions.previous()
      } else if (!IS_RIGHT_SWIPE && state.currentIndex < TOTAL_TRIGGER_ELEMENTS - 1) {
        actions.next()
      }
    }

    actions.updateOffset()
  } else if (state.isDraggingY) {
    if (MOVEMENT_Y_DISTANCE >= state.config.threshold && state.config.swipeClose) {
      actions.close()
    } else {
      state.lightbox.classList.remove('parvus--is-vertical-closing')

      actions.updateOffset()
    }

    state.lightboxOverlay.style.opacity = ''
  } else {
    actions.updateOffset()
  }
}


================================================
FILE: src/js/handlers/images.js
================================================
/**
 * Image Handler Module
 *
 * Handles image loading, captions, and dimensions
 */

/**
 * Add caption to the container element
 *
 * @param {Object} config - Configuration object
 * @param {HTMLElement} containerEl - The container element to which the caption will be added
 * @param {HTMLElement} imageEl - The image the caption is linked to
 * @param {HTMLElement} el - The trigger element associated with the caption
 * @param {Number} index - The index of the caption
 * @returns {void}
 */
export const addCaption = (config, containerEl, imageEl, el, index) => {
  const getCaptionData = (triggerEl) => {
    const { captionsAttribute, captionsSelector, captionsIdAttribute = 'data-caption-id' } = config

    // Check for an ID reference on the trigger element
    // This allows the caption to be anywhere on the page
    const CAPTION_ID = triggerEl.getAttribute(captionsIdAttribute)

    if (CAPTION_ID) {
      const CAPTION_EL = document.getElementById(CAPTION_ID)

      if (CAPTION_EL) {
        return CAPTION_EL.innerHTML
      }
    }

    // Check for a direct caption attribute on the trigger element
    const DIRECT_CAPTION = triggerEl.getAttribute(captionsAttribute)

    if (DIRECT_CAPTION) {
      return DIRECT_CAPTION
    }

    // Query for a selector inside the trigger element
    if (captionsSelector !== 'self') {
      const CAPTION_EL = triggerEl.querySelector(captionsSelector)

      if (CAPTION_EL) {
        // Prefer a direct attribute on the found element, otherwise use its content
        return CAPTION_EL.getAttribute(captionsAttribute) || CAPTION_EL.innerHTML
      }
    }

    return null
  }

  const CAPTION_DATA = getCaptionData(el)

  if (CAPTION_DATA) {
    const CAPTION_CONTAINER = document.createElement('div')
    const CAPTION_ID = `parvus__caption-${index}`

    CAPTION_CONTAINER.className = 'parvus__caption'
    CAPTION_CONTAINER.id = CAPTION_ID
    CAPTION_CONTAINER.innerHTML = `<p>${CAPTION_DATA}</p>`

    containerEl.appendChild(CAPTION_CONTAINER)
    imageEl.setAttribute('aria-describedby', CAPTION_ID)
  }
}

/**
 * Add copyright to the image container element
 *
 * @param {Object} config - Configuration object
 * @param {HTMLElement} imageContainer - The image container element (parvus__content) to which the copyright will be added
 * @param {HTMLElement} imageEl - The image the copyright is linked to
 * @param {HTMLElement} el - The trigger element associated with the copyright
 * @param {Number} index - The index of the copyright
 * @returns {void}
 */
export const addCopyright = (config, imageContainer, imageEl, el, index) => {
  const getCopyrightData = (triggerEl) => {
    const { copyrightAttribute, copyrightSelector, copyrightIdAttribute = 'data-copyright-id' } = config

    // Check for an ID reference on the trigger element
    // This allows the copyright to be anywhere on the page
    const COPYRIGHT_ID = triggerEl.getAttribute(copyrightIdAttribute)

    if (COPYRIGHT_ID) {
      const COPYRIGHT_EL = document.getElementById(COPYRIGHT_ID)

      if (COPYRIGHT_EL) {
        return COPYRIGHT_EL.innerHTML
      }
    }

    // Check for a direct copyright attribute on the trigger element
    const DIRECT_COPYRIGHT = triggerEl.getAttribute(copyrightAttribute)

    if (DIRECT_COPYRIGHT) {
      return DIRECT_COPYRIGHT
    }

    // Query for a selector inside the trigger element
    if (copyrightSelector !== 'self') {
      const COPYRIGHT_EL = triggerEl.querySelector(copyrightSelector)

      if (COPYRIGHT_EL) {
        // Prefer a direct attribute on the found element, otherwise use its content
        return COPYRIGHT_EL.getAttribute(copyrightAttribute) || COPYRIGHT_EL.innerHTML
      }
    }

    return null
  }

  const COPYRIGHT_DATA = getCopyrightData(el)

  if (COPYRIGHT_DATA) {
    const COPYRIGHT_CONTAINER = document.createElement('div')
    const COPYRIGHT_ID = `parvus__copyright-${index}`

    COPYRIGHT_CONTAINER.className = 'parvus__copyright'
    COPYRIGHT_CONTAINER.id = COPYRIGHT_ID
    COPYRIGHT_CONTAINER.innerHTML = `<small>${COPYRIGHT_DATA}</small>`

    imageContainer.appendChild(COPYRIGHT_CONTAINER)

    // If image already has aria-describedby (from caption), append copyright ID
    const existingAriaDescribedby = imageEl.getAttribute('aria-describedby')
    if (existingAriaDescribedby) {
      imageEl.setAttribute('aria-describedby', `${existingAriaDescribedby} ${COPYRIGHT_ID}`)
    } else {
      imageEl.setAttribute('aria-describedby', COPYRIGHT_ID)
    }
  }
}

/**
 * Create image
 *
 * @param {Object} state - The application state
 * @param {HTMLElement} el - The trigger element
 * @param {Number} index - The index
 * @param {Function} callback - Callback function
 * @returns {void}
 */
export const createImage = (state, el, index, callback) => {
  const { contentElements, sliderElements } = state.GROUPS[state.activeGroup]

  if (contentElements[index] !== undefined) {
    if (callback && typeof callback === 'function') {
      callback()
    }
    return
  }

  const CONTENT_CONTAINER_EL = sliderElements[index].querySelector('div')
  const IMAGE = new Image()
  const IMAGE_CONTAINER = document.createElement('div')
  const THUMBNAIL = el.querySelector('img')
  const LOADING_INDICATOR = document.createElement('div')

  IMAGE_CONTAINER.className = 'parvus__content'

  // Create loading indicator
  LOADING_INDICATOR.className = 'parvus__loader'
  LOADING_INDICATOR.setAttribute('role', 'progressbar')
  LOADING_INDICATOR.setAttribute('aria-label', state.config.l10n.lightboxLoadingIndicatorLabel)

  // Add loading indicator to content container
  CONTENT_CONTAINER_EL.appendChild(LOADING_INDICATOR)

  const checkImagePromise = new Promise((resolve, reject) => {
    IMAGE.onload = () => resolve(IMAGE)
    IMAGE.onerror = (error) => reject(error)
  })

  checkImagePromise
    .then((loadedImage) => {
      loadedImage.style.opacity = 0

      IMAGE_CONTAINER.appendChild(loadedImage)

      // Add copyright if available (inside IMAGE_CONTAINER)
      if (state.config.copyright) {
        addCopyright(state.config, IMAGE_CONTAINER, IMAGE, el, index)
      }

      CONTENT_CONTAINER_EL.appendChild(IMAGE_CONTAINER)

      // Add caption if available
      if (state.config.captions) {
        addCaption(state.config, CONTENT_CONTAINER_EL, IMAGE, el, index)
      }

      contentElements[index] = loadedImage

      // Set image width and height
      loadedImage.setAttribute('width', loadedImage.naturalWidth)
      loadedImage.setAttribute('height', loadedImage.naturalHeight)

      // Set image dimension
      setImageDimension(sliderElements[index], loadedImage)
    })
    .catch(() => {
      const ERROR_CONTAINER = document.createElement('div')

      ERROR_CONTAINER.classList.add('parvus__content')
      ERROR_CONTAINER.classList.add('parvus__content--error')

      ERROR_CONTAINER.textContent = state.config.l10n.lightboxLoadingError

      CONTENT_CONTAINER_EL.appendChild(ERROR_CONTAINER)

      contentElements[index] = ERROR_CONTAINER
    })
    .finally(() => {
      CONTENT_CONTAINER_EL.removeChild(LOADING_INDICATOR)

      if (callback && typeof callback === 'function') {
        callback()
      }
    })

  // Add `sizes` attribute
  if (el.hasAttribute('data-sizes') && el.getAttribute('data-sizes') !== '') {
    IMAGE.setAttribute('sizes', el.getAttribute('data-sizes'))
  }

  // Add `srcset` attribute
  if (el.hasAttribute('data-srcset') && el.getAttribute('data-srcset') !== '') {
    IMAGE.setAttribute('srcset', el.getAttribute('data-srcset'))
  }

  // Add `src` attribute
  if (el.tagName === 'A') {
    IMAGE.setAttribute('src', el.href)
  } else {
    IMAGE.setAttribute('src', el.getAttribute('data-target'))
  }

  // `alt` attribute
  if (THUMBNAIL && THUMBNAIL.hasAttribute('alt') && THUMBNAIL.getAttribute('alt') !== '') {
    IMAGE.alt = THUMBNAIL.alt
  } else if (el.hasAttribute('data-alt') && el.getAttribute('data-alt') !== '') {
    IMAGE.alt = el.getAttribute('data-alt')
  } else {
    IMAGE.alt = ''
  }
}

/**
 * Load Image
 *
 * @param {Object} state - The application state
 * @param {Number} index - The index of the image to load
 * @param {Boolean} animate - Whether to animate the image
 * @returns {void}
 */
export const loadImage = (state, index, animate) => {
  const IMAGE = state.GROUPS[state.activeGroup].contentElements[index]

  if (IMAGE && IMAGE.tagName === 'IMG') {
    const THUMBNAIL = state.GROUPS[state.activeGroup].triggerElements[index]

    if (animate && document.startViewTransition) {
      THUMBNAIL.style.viewTransitionName = 'lightboximage'

      const transition = document.startViewTransition(() => {
        IMAGE.style.opacity = ''
        THUMBNAIL.style.viewTransitionName = null

        IMAGE.style.viewTransitionName = 'lightboximage'
      })

      transition.finished.finally(() => {
        IMAGE.style.viewTransitionName = null
      })
    } else {
      IMAGE.style.opacity = ''
    }
  } else {
    IMAGE.style.opacity = ''
  }
}

/**
 * Set image dimension
 *
 * @param {HTMLElement} slideEl - The slide element
 * @param {HTMLElement} contentEl - The content element
 * @returns {void}
 */
export const setImageDimension = (slideEl, contentEl) => {
  if (contentEl.tagName !== 'IMG') {
    return
  }

  const SRC_HEIGHT = contentEl.getAttribute('height')
  const SRC_WIDTH = contentEl.getAttribute('width')

  if (!SRC_HEIGHT || !SRC_WIDTH) {
    return
  }

  const SLIDE_EL_STYLES = getComputedStyle(slideEl)

  const HORIZONTAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingLeft) + parseFloat(SLIDE_EL_STYLES.paddingRight)
  const VERTICAL_PADDING = parseFloat(SLIDE_EL_STYLES.paddingTop) + parseFloat(SLIDE_EL_STYLES.paddingBottom)

  const CAPTION_EL = slideEl.querySelector('.parvus__caption')
  const CAPTION_HEIGHT = CAPTION_EL ? CAPTION_EL.getBoundingClientRect().height : 0

  const MAX_WIDTH = slideEl.offsetWidth - HORIZONTAL_PADDING
  const MAX_HEIGHT = slideEl.offsetHeight - VERTICAL_PADDING - CAPTION_HEIGHT

  const RATIO = Math.min(MAX_WIDTH / SRC_WIDTH || 0, MAX_HEIGHT / SRC_HEIGHT || 0)

  const NEW_WIDTH = SRC_WIDTH * RATIO
  const NEW_HEIGHT = SRC_HEIGHT * RATIO

  const USE_ORIGINAL_SIZE = (SRC_WIDTH <= MAX_WIDTH && SRC_HEIGHT <= MAX_HEIGHT)

  contentEl.style.width = USE_ORIGINAL_SIZE ? '' : `${NEW_WIDTH}px`
  contentEl.style.height = USE_ORIGINAL_SIZE ? '' : `${NEW_HEIGHT}px`
}

/**
 * Create resize handler
 *
 * @param {Object} state - The application state
 * @param {Function} updateOffset - Update offset function
 * @returns {Function} Resize event handler
 */
export const createResizeHandler = (state, updateOffset) => {
  return () => {
    if (!state.resizeTicking) {
      state.resizeTicking = true

      window.requestAnimationFrame(() => {
        state.GROUPS[state.activeGroup].sliderElements.forEach((slide, index) => {
          setImageDimension(slide, state.GROUPS[state.activeGroup].contentElements[index])
        })

        updateOffset()

        state.resizeTicking = false
      })
    }
  }
}


================================================
FILE: src/js/handlers/keyboard.js
================================================
/**
 * Keyboard Event Handler Module
 *
 * Handles all keyboard interactions
 */

import { getFocusableChildren } from '../helpers/dom.js'

/**
 * Create keyboard event handler
 *
 * @param {Object} state - The application state
 * @param {Object} actions - Actions object with navigation functions
 * @returns {Function} Keyboard event handler
 */
export const createKeydownHandler = (state, actions) => {
  return (event) => {
    const FOCUSABLE_CHILDREN = getFocusableChildren(state.lightbox)
    const FOCUSED_ITEM_INDEX = FOCUSABLE_CHILDREN.indexOf(document.activeElement)
    const lastIndex = FOCUSABLE_CHILDREN.length - 1

    switch (event.code) {
      case 'Tab': {
        // Use the TAB key to navigate backwards and forwards
        if (event.shiftKey) {
          // Navigate backwards
          if (FOCUSED_ITEM_INDEX === 0) {
            FOCUSABLE_CHILDREN[lastIndex].focus()
            event.preventDefault()
          }
        } else {
          // Navigate forwards
          if (FOCUSED_ITEM_INDEX === lastIndex) {
            FOCUSABLE_CHILDREN[0].focus()
            event.preventDefault()
          }
        }
        break
      }
      case 'Escape': {
        // Close Parvus when the ESC key is pressed
        actions.close()
        event.preventDefault()
        break
      }
      case 'ArrowLeft': {
        // Show the previous slide when the PREV key is pressed
        actions.previous()
        event.preventDefault()
        break
      }
      case 'ArrowRight': {
        // Show the next slide when the NEXT key is pressed
        actions.next()
        event.preventDefault()
        break
      }
    }
  }
}


================================================
FILE: src/js/handlers/pointer.js
================================================
/**
 * Pointer Event Handler Module
 *
 * Handles all pointer interactions (mouse, touch, pen)
 */

/**
 * Create pointerdown event handler
 *
 * @param {Object} state - The application state
 * @returns {Function} Pointerdown event handler
 */
export const createPointerdownHandler = (state) => {
  return (event) => {
    event.preventDefault()
    event.stopPropagation()

    state.isDraggingX = false
    state.isDraggingY = false

    state.pointerDown = true

Download .txt
gitextract_wwaefxjx/

├── .github/
│   └── FUNDING.yml
├── .gitignore
├── .stylelintrc
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── dist/
│   ├── css/
│   │   └── parvus.css
│   └── js/
│       ├── parvus.esm.js
│       └── parvus.js
├── package.json
├── rollup.config.js
├── src/
│   ├── js/
│   │   ├── core/
│   │   │   ├── config.js
│   │   │   ├── events.js
│   │   │   ├── navigation.js
│   │   │   ├── plugins.js
│   │   │   ├── state.js
│   │   │   └── utils.js
│   │   ├── handlers/
│   │   │   ├── gestures.js
│   │   │   ├── images.js
│   │   │   ├── keyboard.js
│   │   │   └── pointer.js
│   │   ├── helpers/
│   │   │   └── dom.js
│   │   ├── parvus.js
│   │   └── ui/
│   │       ├── lightbox.js
│   │       └── zoom-indicator.js
│   ├── l10n/
│   │   ├── de.js
│   │   ├── en.js
│   │   ├── fr.js
│   │   ├── it.js
│   │   └── nl.js
│   └── scss/
│       └── parvus.scss
└── test/
    └── test.html
Download .txt
SYMBOL INDEX (51 symbols across 7 files)

FILE: dist/js/parvus.esm.js
  constant BROWSER_WINDOW (line 11) | const BROWSER_WINDOW = window;
  constant FOCUSABLE_ELEMENTS (line 21) | const FOCUSABLE_ELEMENTS = ['a:not([inert]):not([tabindex^="-"])', 'butt...
  constant DEFAULT_OPTIONS (line 47) | const DEFAULT_OPTIONS = {
  class ParvusState (line 94) | class ParvusState {
    method constructor (line 95) | constructor() {
    method clearDrag (line 149) | clearDrag() {
    method getActiveGroup (line 163) | getActiveGroup() {
    method resetZoomState (line 170) | resetZoomState() {
  class PluginManager (line 338) | class PluginManager {
    method constructor (line 339) | constructor() {
    method register (line 352) | register(plugin, options = {}) {
    method installPlugin (line 383) | installPlugin(plugin, options) {
    method install (line 403) | install(context) {
    method executeHook (line 420) | executeHook(hookName, data) {
    method addHook (line 437) | addHook(hookName, callback) {
    method removeHook (line 450) | removeHook(hookName, callback) {
    method getPlugins (line 460) | getPlugins() {
  function Parvus (line 1495) | function Parvus(userOptions) {

FILE: dist/js/parvus.js
  class ParvusState (line 100) | class ParvusState {
    method constructor (line 101) | constructor() {
    method clearDrag (line 155) | clearDrag() {
    method getActiveGroup (line 169) | getActiveGroup() {
    method resetZoomState (line 176) | resetZoomState() {
  class PluginManager (line 344) | class PluginManager {
    method constructor (line 345) | constructor() {
    method register (line 358) | register(plugin, options = {}) {
    method installPlugin (line 389) | installPlugin(plugin, options) {
    method install (line 409) | install(context) {
    method executeHook (line 426) | executeHook(hookName, data) {
    method addHook (line 443) | addHook(hookName, callback) {
    method removeHook (line 456) | removeHook(hookName, callback) {
    method getPlugins (line 466) | getPlugins() {
  function Parvus (line 1501) | function Parvus(userOptions) {

FILE: src/js/core/config.js
  constant DEFAULT_OPTIONS (line 6) | const DEFAULT_OPTIONS = {

FILE: src/js/core/plugins.js
  class PluginManager (line 7) | class PluginManager {
    method constructor (line 8) | constructor () {
    method register (line 21) | register (plugin, options = {}) {
    method installPlugin (line 51) | installPlugin (plugin, options) {
    method install (line 69) | install (context) {
    method executeHook (line 84) | executeHook (hookName, data) {
    method addHook (line 101) | addHook (hookName, callback) {
    method removeHook (line 114) | removeHook (hookName, callback) {
    method getPlugins (line 125) | getPlugins () {

FILE: src/js/core/state.js
  class ParvusState (line 6) | class ParvusState {
    method constructor (line 7) | constructor () {
    method clearDrag (line 61) | clearDrag () {
    method getActiveGroup (line 75) | getActiveGroup () {
    method resetZoomState (line 82) | resetZoomState () {

FILE: src/js/helpers/dom.js
  constant BROWSER_WINDOW (line 1) | const BROWSER_WINDOW = window
  constant FOCUSABLE_ELEMENTS (line 12) | const FOCUSABLE_ELEMENTS = [

FILE: src/js/parvus.js
  function Parvus (line 28) | function Parvus (userOptions) {
Condensed preview — 32 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (267K chars).
[
  {
    "path": ".github/FUNDING.yml",
    "chars": 647,
    "preview": "# These are supported funding model platforms\n\ngithub: [deoostfrees]\npatreon: # Replace with a single Patreon username\no"
  },
  {
    "path": ".gitignore",
    "chars": 31,
    "preview": ".DS_Store\n.vscode\nnode_modules\n"
  },
  {
    "path": ".stylelintrc",
    "chars": 963,
    "preview": "{\n  \"extends\": [\n    \"stylelint-config-standard-scss\"\n  ],\n  \"plugins\": [\n    \"stylelint-scss\",\n    \"stylelint-use-logic"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 3818,
    "preview": "# Changelog\n\n## [3.1.0] - 2026-04-18\n\n### Added\n\n- Add copyright information to an image e598627 @deoostfrees\n- Add plug"
  },
  {
    "path": "LICENSE.md",
    "chars": 1095,
    "preview": "# The MIT License (MIT)\n\nCopyright (c) 2020-2026 Benjamin de Oostfrees\n\nPermission is hereby granted, free of charge, to"
  },
  {
    "path": "README.md",
    "chars": 11154,
    "preview": "# Parvus\n\nOverlays suck, but if you need one, consider using Parvus. Parvus is an open source, dependency free image lig"
  },
  {
    "path": "dist/css/parvus.css",
    "chars": 7791,
    "preview": ":root {\n  --parvus-transition-duration: 0.3s;\n  --parvus-transition-timing-function: cubic-bezier(0.62, 0.16, 0.13, 1.01"
  },
  {
    "path": "dist/js/parvus.esm.js",
    "chars": 65646,
    "preview": "/**\n * Parvus\n *\n * @author Benjamin de Oostfrees\n * @version 3.1.0\n * @url https://github.com/deoostfrees/parvus\n *\n * "
  },
  {
    "path": "dist/js/parvus.js",
    "chars": 69748,
    "preview": "/**\n * Parvus\n *\n * @author Benjamin de Oostfrees\n * @version 3.1.0\n * @url https://github.com/deoostfrees/parvus\n *\n * "
  },
  {
    "path": "package.json",
    "chars": 2335,
    "preview": "{\n  \"name\": \"parvus\",\n  \"type\": \"module\",\n  \"version\": \"3.1.0\",\n  \"description\": \"An open source, dependency free image "
  },
  {
    "path": "rollup.config.js",
    "chars": 2788,
    "preview": "import resolve from '@rollup/plugin-node-resolve'\nimport commonjs from '@rollup/plugin-commonjs'\nimport terser from '@ro"
  },
  {
    "path": "src/js/core/config.js",
    "chars": 2196,
    "preview": "import en from '../../l10n/en.js'\n\n/**\n * Default configuration options\n */\nexport const DEFAULT_OPTIONS = {\n  selector:"
  },
  {
    "path": "src/js/core/events.js",
    "chars": 1170,
    "preview": "/**\n * Event System Module\n *\n * Handles custom event dispatching and listeners\n */\n\n/**\n * Dispatch a custom event\n *\n "
  },
  {
    "path": "src/js/core/navigation.js",
    "chars": 1963,
    "preview": "/**\n * Navigation Module\n *\n * Handles slide navigation and transitions\n */\n\n/**\n * Update offset\n *\n * @param {Object} "
  },
  {
    "path": "src/js/core/plugins.js",
    "chars": 3138,
    "preview": "/**\n * Plugin management for Parvus\n *\n * Provides a system for registering and managing plugins\n */\n\nexport class Plugi"
  },
  {
    "path": "src/js/core/state.js",
    "chars": 1763,
    "preview": "/**\n * State management for Parvus\n *\n * Centralizes all mutable state variables\n */\nexport class ParvusState {\n  constr"
  },
  {
    "path": "src/js/core/utils.js",
    "chars": 1039,
    "preview": "/**\n * Utils Module\n *\n * Utility functions\n */\n\n/**\n * Check prefers reduced motion\n *\n * @param {Object} state - The a"
  },
  {
    "path": "src/js/handlers/gestures.js",
    "chars": 6924,
    "preview": "/**\n * Gesture Handler Module\n *\n * Handles gestures like pinch-to-zoom and swipe\n */\n\n/**\n * Reset image zoom\n *\n * @pa"
  },
  {
    "path": "src/js/handlers/images.js",
    "chars": 11031,
    "preview": "/**\n * Image Handler Module\n *\n * Handles image loading, captions, and dimensions\n */\n\n/**\n * Add caption to the contain"
  },
  {
    "path": "src/js/handlers/keyboard.js",
    "chars": 1657,
    "preview": "/**\n * Keyboard Event Handler Module\n *\n * Handles all keyboard interactions\n */\n\nimport { getFocusableChildren } from '"
  },
  {
    "path": "src/js/handlers/pointer.js",
    "chars": 4068,
    "preview": "/**\n * Pointer Event Handler Module\n *\n * Handles all pointer interactions (mouse, touch, pen)\n */\n\n/**\n * Create pointe"
  },
  {
    "path": "src/js/helpers/dom.js",
    "chars": 715,
    "preview": "const BROWSER_WINDOW = window\n\n/**\n * Get scrollbar width\n *\n * @return {Number} - The scrollbar width\n */\nexport const "
  },
  {
    "path": "src/js/parvus.js",
    "chars": 19310,
    "preview": "// Helper modules\nimport { getScrollbarWidth } from './helpers/dom.js'\n\n// Core modules\nimport { mergeOptions } from './"
  },
  {
    "path": "src/js/ui/lightbox.js",
    "chars": 9849,
    "preview": "/**\n * UI Components Module\n *\n * Handles creation of lightbox, toolbar, slider and slides\n */\n\n/**\n * Create the lightb"
  },
  {
    "path": "src/js/ui/zoom-indicator.js",
    "chars": 935,
    "preview": "/**\n * Add zoom indicator to element\n *\n * @param {HTMLElement} el - The element to add the zoom indicator to\n * @param "
  },
  {
    "path": "src/l10n/de.js",
    "chars": 611,
    "preview": "export default {\n  lightboxLabel: 'Dies ist ein Dialogfenster, das den Hauptinhalt der Seite überlagert. Das Modal zeigt"
  },
  {
    "path": "src/l10n/en.js",
    "chars": 549,
    "preview": "export default {\n  lightboxLabel: 'This is a dialog window that overlays the main content of the page. The modal display"
  },
  {
    "path": "src/l10n/fr.js",
    "chars": 621,
    "preview": "export default {\r\n  lightboxLabel: 'Il s\\'agit d\\'une boîte de dialogue superposée au contenu principal de la page. La f"
  },
  {
    "path": "src/l10n/it.js",
    "chars": 598,
    "preview": "export default {\n  lightboxLabel: 'Questa è una finestra di dialogo che si sovrappone al contenuto principale della pagi"
  },
  {
    "path": "src/l10n/nl.js",
    "chars": 657,
    "preview": "export default {\n  lightboxLabel: 'Dit is een dialoogvenster dat over de hoofdinhoud van de pagina wordt geplaatst. Hier"
  },
  {
    "path": "src/scss/parvus.scss",
    "chars": 7657,
    "preview": ":root {\n  // Transition\n  --parvus-transition-duration: 0.3s;\n  --parvus-transition-timing-function: cubic-bezier(0.62, "
  },
  {
    "path": "test/test.html",
    "chars": 12620,
    "preview": "<!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-wid"
  }
]

About this extraction

This page contains the full source code of the deoostfreese/Parvus GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 32 files (249.1 KB), approximately 64.7k tokens, and a symbol index with 51 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!