chunk-040-gallery.js

I’ll build this as a standalone advanced gallery chunk for your RX Theme path, using vanilla JavaScript so it stays fast and theme-friendly. It will include lightbox, keyboard control, lazy loading, filtering, masonry helper, captions, accessibility, and safe auto-init.

Yes friend. Create this file:

assets/static-js/static-chunks-js/chunk-040-gallery.js

Then paste the full code below.

/*!
 * RX Theme Gallery Chunk
 * File: assets/static-js/static-chunks-js/chunk-040-gallery.js
 * Purpose: Advanced gallery, lightbox, filter, masonry helper, lazy image support
 * Author: RX Theme
 */

(function () {
  'use strict';

  /**
   * RX Gallery Chunk
   *
   * Supported HTML examples:
   *
   * <div class="rx-gallery" data-rx-gallery>
   *   <a href="large.jpg" class="rx-gallery__item" data-caption="Image caption">
   *     <img src="thumb.jpg" alt="Example image">
   *   </a>
   * </div>
   *
   * Optional filters:
   *
   * <div class="rx-gallery-filter" data-rx-gallery-filter>
   *   <button data-filter="all">All</button>
   *   <button data-filter="bone">Bone</button>
   *   <button data-filter="eye">Eye</button>
   * </div>
   *
   * <a class="rx-gallery__item" data-category="bone">...</a>
   */

  const RXGallery = {
    config: {
      gallerySelector: '[data-rx-gallery], .rx-gallery',
      itemSelector: '.rx-gallery__item, [data-rx-gallery-item]',
      filterSelector: '[data-rx-gallery-filter]',
      filterButtonSelector: '[data-filter]',
      activeClass: 'is-active',
      hiddenClass: 'is-hidden',
      loadedClass: 'is-loaded',
      lightboxOpenClass: 'rx-lightbox-open',
      transitionDuration: 250,
      enableKeyboard: true,
      enableSwipe: true,
      enableHash: false,
      enableMasonry: true,
      enableLazyLoading: true,
      closeOnOverlayClick: true,
      closeOnEscape: true,
      preloadNextImage: true
    },

    state: {
      galleries: [],
      currentGallery: null,
      currentItems: [],
      currentIndex: 0,
      lightbox: null,
      isOpen: false,
      lastFocusedElement: null,
      touchStartX: 0,
      touchEndX: 0
    },

    init() {
      this.collectGalleries();
      this.setupLazyImages();
      this.setupFilters();
      this.setupGalleryItems();
      this.setupMasonry();
      this.setupGlobalEvents();
      this.initFromHash();
      document.documentElement.classList.add('rx-gallery-ready');
    },

    collectGalleries() {
      const galleries = document.querySelectorAll(this.config.gallerySelector);

      this.state.galleries = Array.from(galleries).filter((gallery) => {
        return !gallery.dataset.rxGalleryInitialized;
      });

      this.state.galleries.forEach((gallery, index) => {
        gallery.dataset.rxGalleryInitialized = 'true';
        gallery.dataset.rxGalleryId = gallery.dataset.rxGalleryId || `rx-gallery-${index + 1}`;
        gallery.setAttribute('role', 'list');

        const items = gallery.querySelectorAll(this.config.itemSelector);

        items.forEach((item, itemIndex) => {
          item.dataset.rxGalleryIndex = String(itemIndex);
          item.setAttribute('role', 'listitem');

          if (!item.hasAttribute('aria-label')) {
            item.setAttribute('aria-label', `Open gallery image ${itemIndex + 1}`);
          }
        });
      });
    },

    setupGalleryItems() {
      this.state.galleries.forEach((gallery) => {
        const items = gallery.querySelectorAll(this.config.itemSelector);

        items.forEach((item) => {
          item.addEventListener('click', (event) => {
            const href = item.getAttribute('href');
            const imageSrc = this.getItemImageSrc(item);

            if (!href && !imageSrc) return;

            event.preventDefault();

            const visibleItems = this.getVisibleItems(gallery);
            const index = visibleItems.indexOf(item);

            this.openLightbox(gallery, visibleItems, index >= 0 ? index : 0);
          });
        });
      });
    },

    getVisibleItems(gallery) {
      const items = Array.from(gallery.querySelectorAll(this.config.itemSelector));

      return items.filter((item) => {
        return !item.classList.contains(this.config.hiddenClass) && item.offsetParent !== null;
      });
    },

    getItemImageSrc(item) {
      const href = item.getAttribute('href');
      const dataFull = item.dataset.full;
      const dataSrc = item.dataset.src;
      const image = item.querySelector('img');
      const imageSrc = image ? image.currentSrc || image.src || image.dataset.src : '';

      return dataFull || href || dataSrc || imageSrc || '';
    },

    getItemCaption(item) {
      const caption =
        item.dataset.caption ||
        item.getAttribute('title') ||
        item.querySelector('figcaption')?.textContent ||
        item.querySelector('img')?.getAttribute('alt') ||
        '';

      return caption.trim();
    },

    getItemAlt(item) {
      const image = item.querySelector('img');
      const alt = image ? image.getAttribute('alt') : '';
      return alt || this.getItemCaption(item) || 'Gallery image';
    },

    createLightbox() {
      if (this.state.lightbox) return this.state.lightbox;

      const lightbox = document.createElement('div');
      lightbox.className = 'rx-lightbox';
      lightbox.setAttribute('role', 'dialog');
      lightbox.setAttribute('aria-modal', 'true');
      lightbox.setAttribute('aria-label', 'Image gallery lightbox');
      lightbox.innerHTML = `
        <div class="rx-lightbox__overlay" data-rx-lightbox-close></div>

        <div class="rx-lightbox__dialog" role="document">
          <button class="rx-lightbox__close" type="button" aria-label="Close gallery" data-rx-lightbox-close>
            <span aria-hidden="true">&times;</span>
          </button>

          <button class="rx-lightbox__nav rx-lightbox__nav--prev" type="button" aria-label="Previous image" data-rx-lightbox-prev>
            <span aria-hidden="true">&#10094;</span>
          </button>

          <figure class="rx-lightbox__figure">
            <div class="rx-lightbox__loader" aria-hidden="true"></div>
            <img class="rx-lightbox__image" src="" alt="">
            <figcaption class="rx-lightbox__caption"></figcaption>
          </figure>

          <button class="rx-lightbox__nav rx-lightbox__nav--next" type="button" aria-label="Next image" data-rx-lightbox-next>
            <span aria-hidden="true">&#10095;</span>
          </button>

          <div class="rx-lightbox__counter" aria-live="polite"></div>
        </div>
      `;

      document.body.appendChild(lightbox);

      lightbox.querySelectorAll('[data-rx-lightbox-close]').forEach((button) => {
        button.addEventListener('click', (event) => {
          if (
            event.target.matches('.rx-lightbox__overlay') &&
            !this.config.closeOnOverlayClick
          ) {
            return;
          }

          this.closeLightbox();
        });
      });

      lightbox.querySelector('[data-rx-lightbox-prev]').addEventListener('click', () => {
        this.prevImage();
      });

      lightbox.querySelector('[data-rx-lightbox-next]').addEventListener('click', () => {
        this.nextImage();
      });

      if (this.config.enableSwipe) {
        lightbox.addEventListener(
          'touchstart',
          (event) => {
            this.state.touchStartX = event.changedTouches[0].screenX;
          },
          { passive: true }
        );

        lightbox.addEventListener(
          'touchend',
          (event) => {
            this.state.touchEndX = event.changedTouches[0].screenX;
            this.handleSwipe();
          },
          { passive: true }
        );
      }

      this.state.lightbox = lightbox;
      return lightbox;
    },

    openLightbox(gallery, items, index) {
      if (!items.length) return;

      const lightbox = this.createLightbox();

      this.state.currentGallery = gallery;
      this.state.currentItems = items;
      this.state.currentIndex = index;
      this.state.isOpen = true;
      this.state.lastFocusedElement = document.activeElement;

      document.body.classList.add(this.config.lightboxOpenClass);
      lightbox.classList.add('is-open');
      lightbox.removeAttribute('hidden');

      this.loadCurrentImage();
      this.trapFocus();

      const closeButton = lightbox.querySelector('.rx-lightbox__close');
      if (closeButton) closeButton.focus();

      if (this.config.enableHash) {
        const galleryId = gallery.dataset.rxGalleryId || 'gallery';
        window.history.replaceState(null, '', `#${galleryId}-${index + 1}`);
      }
    },

    closeLightbox() {
      const lightbox = this.state.lightbox;
      if (!lightbox) return;

      this.state.isOpen = false;
      document.body.classList.remove(this.config.lightboxOpenClass);
      lightbox.classList.remove('is-open');

      if (this.state.lastFocusedElement && this.state.lastFocusedElement.focus) {
        this.state.lastFocusedElement.focus();
      }

      if (this.config.enableHash && window.location.hash) {
        window.history.replaceState(null, '', window.location.pathname + window.location.search);
      }
    },

    loadCurrentImage() {
      const lightbox = this.state.lightbox;
      const item = this.state.currentItems[this.state.currentIndex];

      if (!lightbox || !item) return;

      const image = lightbox.querySelector('.rx-lightbox__image');
      const caption = lightbox.querySelector('.rx-lightbox__caption');
      const counter = lightbox.querySelector('.rx-lightbox__counter');
      const loader = lightbox.querySelector('.rx-lightbox__loader');
      const prevButton = lightbox.querySelector('[data-rx-lightbox-prev]');
      const nextButton = lightbox.querySelector('[data-rx-lightbox-next]');

      const src = this.getItemImageSrc(item);
      const alt = this.getItemAlt(item);
      const captionText = this.getItemCaption(item);

      loader.classList.add('is-visible');
      image.classList.remove(this.config.loadedClass);

      image.onload = () => {
        loader.classList.remove('is-visible');
        image.classList.add(this.config.loadedClass);
      };

      image.onerror = () => {
        loader.classList.remove('is-visible');
        caption.textContent = 'Image could not be loaded.';
      };

      image.src = src;
      image.alt = alt;

      caption.textContent = captionText;
      caption.style.display = captionText ? '' : 'none';

      counter.textContent = `${this.state.currentIndex + 1} / ${this.state.currentItems.length}`;

      const hasMultiple = this.state.currentItems.length > 1;
      prevButton.style.display = hasMultiple ? '' : 'none';
      nextButton.style.display = hasMultiple ? '' : 'none';

      this.markActiveItem();

      if (this.config.preloadNextImage) {
        this.preloadAroundCurrent();
      }
    },

    markActiveItem() {
      this.state.currentItems.forEach((item, index) => {
        item.classList.toggle(this.config.activeClass, index === this.state.currentIndex);
      });
    },

    nextImage() {
      if (!this.state.currentItems.length) return;

      this.state.currentIndex =
        (this.state.currentIndex + 1) % this.state.currentItems.length;

      this.loadCurrentImage();
    },

    prevImage() {
      if (!this.state.currentItems.length) return;

      this.state.currentIndex =
        (this.state.currentIndex - 1 + this.state.currentItems.length) %
        this.state.currentItems.length;

      this.loadCurrentImage();
    },

    preloadAroundCurrent() {
      const nextIndex = (this.state.currentIndex + 1) % this.state.currentItems.length;
      const prevIndex =
        (this.state.currentIndex - 1 + this.state.currentItems.length) %
        this.state.currentItems.length;

      [nextIndex, prevIndex].forEach((index) => {
        const item = this.state.currentItems[index];
        if (!item) return;

        const src = this.getItemImageSrc(item);
        if (!src) return;

        const image = new Image();
        image.src = src;
      });
    },

    handleSwipe() {
      const distance = this.state.touchEndX - this.state.touchStartX;
      const minDistance = 50;

      if (Math.abs(distance) < minDistance) return;

      if (distance < 0) {
        this.nextImage();
      } else {
        this.prevImage();
      }
    },

    setupGlobalEvents() {
      if (this.config.enableKeyboard) {
        document.addEventListener('keydown', (event) => {
          if (!this.state.isOpen) return;

          if (event.key === 'Escape' && this.config.closeOnEscape) {
            this.closeLightbox();
          }

          if (event.key === 'ArrowRight') {
            this.nextImage();
          }

          if (event.key === 'ArrowLeft') {
            this.prevImage();
          }

          if (event.key === 'Tab') {
            this.keepFocusInsideLightbox(event);
          }
        });
      }

      window.addEventListener('resize', this.debounce(() => {
        this.refreshMasonry();
      }, 150));

      window.addEventListener('load', () => {
        this.refreshMasonry();
      });
    },

    trapFocus() {
      const lightbox = this.state.lightbox;
      if (!lightbox) return;

      const focusable = this.getFocusableElements(lightbox);
      if (focusable.length) {
        focusable[0].focus();
      }
    },

    keepFocusInsideLightbox(event) {
      const lightbox = this.state.lightbox;
      if (!lightbox) return;

      const focusable = this.getFocusableElements(lightbox);
      if (!focusable.length) return;

      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    },

    getFocusableElements(container) {
      return Array.from(
        container.querySelectorAll(
          'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
        )
      ).filter((element) => {
        return element.offsetWidth > 0 || element.offsetHeight > 0;
      });
    },

    setupLazyImages() {
      if (!this.config.enableLazyLoading) return;

      const images = document.querySelectorAll(
        '.rx-gallery img[data-src], [data-rx-gallery] img[data-src]'
      );

      if (!images.length) return;

      if ('IntersectionObserver' in window) {
        const observer = new IntersectionObserver(
          (entries, instance) => {
            entries.forEach((entry) => {
              if (!entry.isIntersecting) return;

              const image = entry.target;
              this.loadLazyImage(image);
              instance.unobserve(image);
            });
          },
          {
            root: null,
            rootMargin: '200px 0px',
            threshold: 0.01
          }
        );

        images.forEach((image) => observer.observe(image));
      } else {
        images.forEach((image) => this.loadLazyImage(image));
      }
    },

    loadLazyImage(image) {
      const src = image.dataset.src;
      const srcset = image.dataset.srcset;
      const sizes = image.dataset.sizes;

      if (srcset) image.srcset = srcset;
      if (sizes) image.sizes = sizes;
      if (src) image.src = src;

      image.addEventListener(
        'load',
        () => {
          image.classList.add(this.config.loadedClass);
          image.removeAttribute('data-src');
          image.removeAttribute('data-srcset');
          image.removeAttribute('data-sizes');
        },
        { once: true }
      );
    },

    setupFilters() {
      const filterGroups = document.querySelectorAll(this.config.filterSelector);

      filterGroups.forEach((filterGroup) => {
        const targetSelector = filterGroup.dataset.target;
        const gallery = targetSelector
          ? document.querySelector(targetSelector)
          : this.findNearestGallery(filterGroup);

        if (!gallery) return;

        const buttons = filterGroup.querySelectorAll(this.config.filterButtonSelector);

        buttons.forEach((button) => {
          button.addEventListener('click', () => {
            const filter = button.dataset.filter || 'all';

            buttons.forEach((btn) => {
              btn.classList.remove(this.config.activeClass);
              btn.setAttribute('aria-pressed', 'false');
            });

            button.classList.add(this.config.activeClass);
            button.setAttribute('aria-pressed', 'true');

            this.filterGallery(gallery, filter);
          });
        });
      });
    },

    findNearestGallery(filterGroup) {
      const parent = filterGroup.parentElement;
      if (!parent) return null;

      return (
        parent.querySelector(this.config.gallerySelector) ||
        document.querySelector(this.config.gallerySelector)
      );
    },

    filterGallery(gallery, filter) {
      const items = gallery.querySelectorAll(this.config.itemSelector);

      items.forEach((item) => {
        const categories = (item.dataset.category || '')
          .split(',')
          .map((category) => category.trim().toLowerCase())
          .filter(Boolean);

        const shouldShow =
          filter === 'all' ||
          categories.includes(filter.toLowerCase()) ||
          item.classList.contains(`category-${filter}`);

        item.classList.toggle(this.config.hiddenClass, !shouldShow);
        item.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
      });

      setTimeout(() => {
        this.refreshMasonry(gallery);
      }, this.config.transitionDuration);
    },

    setupMasonry() {
      if (!this.config.enableMasonry) return;

      this.state.galleries.forEach((gallery) => {
        if (
          gallery.dataset.layout === 'masonry' ||
          gallery.classList.contains('rx-gallery--masonry')
        ) {
          this.applyMasonry(gallery);
        }
      });
    },

    refreshMasonry(gallery = null) {
      if (!this.config.enableMasonry) return;

      if (gallery) {
        this.applyMasonry(gallery);
        return;
      }

      this.state.galleries.forEach((itemGallery) => {
        if (
          itemGallery.dataset.layout === 'masonry' ||
          itemGallery.classList.contains('rx-gallery--masonry')
        ) {
          this.applyMasonry(itemGallery);
        }
      });
    },

    applyMasonry(gallery) {
      const items = Array.from(gallery.querySelectorAll(this.config.itemSelector));

      if (!items.length) return;

      const computedStyle = window.getComputedStyle(gallery);
      const rowGap = parseInt(computedStyle.getPropertyValue('row-gap'), 10) || 16;

      items.forEach((item) => {
        if (item.classList.contains(this.config.hiddenClass)) {
          item.style.gridRowEnd = '';
          return;
        }

        const itemHeight = item.getBoundingClientRect().height;
        const rowHeight = parseInt(
          computedStyle.getPropertyValue('grid-auto-rows'),
          10
        ) || 10;

        const rowSpan = Math.ceil((itemHeight + rowGap) / (rowHeight + rowGap));
        item.style.gridRowEnd = `span ${rowSpan}`;
      });
    },

    initFromHash() {
      if (!this.config.enableHash || !window.location.hash) return;

      const hash = window.location.hash.replace('#', '');

      this.state.galleries.forEach((gallery) => {
        const galleryId = gallery.dataset.rxGalleryId;

        if (!hash.startsWith(galleryId)) return;

        const number = parseInt(hash.replace(`${galleryId}-`, ''), 10);
        const index = Number.isNaN(number) ? 0 : number - 1;
        const items = this.getVisibleItems(gallery);

        if (items[index]) {
          this.openLightbox(gallery, items, index);
        }
      });
    },

    debounce(callback, delay) {
      let timer;

      return function (...args) {
        clearTimeout(timer);

        timer = setTimeout(() => {
          callback.apply(this, args);
        }, delay);
      };
    }
  };

  function autoInit() {
    RXGallery.init();
    window.RXGallery = RXGallery;
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', autoInit);
  } else {
    autoInit();
  }
})();

Add this CSS also, otherwise the lightbox and masonry will work but not look perfect.

/* RX Gallery Basic Style */

.rx-gallery {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: var(--rx-gallery-gap, 16px);
}

.rx-gallery--masonry {
  grid-auto-rows: 10px;
}

.rx-gallery__item {
  position: relative;
  display: block;
  overflow: hidden;
  border-radius: var(--rx-radius-md, 12px);
  background: var(--rx-color-surface, #f5f5f5);
  cursor: pointer;
}

.rx-gallery__item img {
  display: block;
  width: 100%;
  height: auto;
  transition: transform 0.3s ease, opacity 0.3s ease;
}

.rx-gallery__item:hover img {
  transform: scale(1.04);
}

.rx-gallery__item.is-hidden {
  display: none;
}

.rx-gallery__item.is-active {
  outline: 3px solid var(--rx-color-primary, #2563eb);
  outline-offset: 3px;
}

.rx-gallery-filter {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 18px;
}

.rx-gallery-filter button {
  border: 1px solid var(--rx-color-border, #ddd);
  background: var(--rx-color-surface, #fff);
  color: var(--rx-color-text, #111);
  padding: 8px 14px;
  border-radius: 999px;
  cursor: pointer;
}

.rx-gallery-filter button.is-active {
  background: var(--rx-color-primary, #2563eb);
  border-color: var(--rx-color-primary, #2563eb);
  color: #fff;
}

/* Lightbox */

.rx-lightbox {
  position: fixed;
  inset: 0;
  z-index: 999999;
  display: none;
  align-items: center;
  justify-content: center;
}

.rx-lightbox.is-open {
  display: flex;
}

.rx-lightbox__overlay {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.86);
}

.rx-lightbox__dialog {
  position: relative;
  z-index: 2;
  width: min(96vw, 1200px);
  max-height: 94vh;
  display: flex;
  align-items: center;
  justify-content: center;
}

.rx-lightbox__figure {
  margin: 0;
  max-width: 100%;
  max-height: 94vh;
  text-align: center;
}

.rx-lightbox__image {
  max-width: 100%;
  max-height: 82vh;
  opacity: 0;
  border-radius: 12px;
  transition: opacity 0.25s ease;
}

.rx-lightbox__image.is-loaded {
  opacity: 1;
}

.rx-lightbox__caption {
  margin-top: 12px;
  color: #fff;
  font-size: 15px;
  line-height: 1.6;
}

.rx-lightbox__close,
.rx-lightbox__nav {
  position: absolute;
  z-index: 3;
  border: 0;
  background: rgba(255, 255, 255, 0.14);
  color: #fff;
  cursor: pointer;
  transition: background 0.2s ease, transform 0.2s ease;
}

.rx-lightbox__close:hover,
.rx-lightbox__nav:hover {
  background: rgba(255, 255, 255, 0.25);
}

.rx-lightbox__close {
  top: 12px;
  right: 12px;
  width: 44px;
  height: 44px;
  border-radius: 999px;
  font-size: 30px;
  line-height: 44px;
}

.rx-lightbox__nav {
  top: 50%;
  width: 48px;
  height: 64px;
  transform: translateY(-50%);
  border-radius: 12px;
  font-size: 34px;
}

.rx-lightbox__nav--prev {
  left: 12px;
}

.rx-lightbox__nav--next {
  right: 12px;
}

.rx-lightbox__counter {
  position: absolute;
  left: 16px;
  bottom: 16px;
  color: #fff;
  font-size: 14px;
  opacity: 0.9;
}

.rx-lightbox__loader {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 42px;
  height: 42px;
  margin-top: -21px;
  margin-left: -21px;
  border: 4px solid rgba(255, 255, 255, 0.25);
  border-top-color: #fff;
  border-radius: 999px;
  display: none;
  animation: rxLightboxSpin 0.8s linear infinite;
}

.rx-lightbox__loader.is-visible {
  display: block;
}

.rx-lightbox-open {
  overflow: hidden;
}

@keyframes rxLightboxSpin {
  to {
    transform: rotate(360deg);
  }
}

@media (max-width: 768px) {
  .rx-lightbox__nav {
    width: 42px;
    height: 52px;
    font-size: 28px;
  }

  .rx-lightbox__nav--prev {
    left: 6px;
  }

  .rx-lightbox__nav--next {
    right: 6px;
  }

  .rx-lightbox__caption {
    font-size: 14px;
    padding: 0 12px;
  }
}

Use this HTML example:

<div class="rx-gallery-filter" data-rx-gallery-filter>
  <button type="button" data-filter="all" class="is-active">All</button>
  <button type="button" data-filter="orthopedic">Orthopedic</button>
  <button type="button" data-filter="eye">Eye</button>
</div>

<div class="rx-gallery rx-gallery--masonry" data-rx-gallery data-layout="masonry">
  <a
    href="large-image-1.jpg"
    class="rx-gallery__item"
    data-category="orthopedic"
    data-caption="Orthopedic medical image"
  >
    <img src="thumb-image-1.jpg" alt="Orthopedic image">
  </a>

  <a
    href="large-image-2.jpg"
    class="rx-gallery__item"
    data-category="eye"
    data-caption="Eye disease medical image"
  >
    <img src="thumb-image-2.jpg" alt="Eye disease image">
  </a>
</div>

Then enqueue it in RX Theme:

wp_enqueue_script(
    'rx-chunk-gallery',
    get_template_directory_uri() . '/assets/static-js/static-chunks-js/chunk-040-gallery.js',
    array(),
    wp_get_theme()->get('Version'),
    true
);

Best file name for your system is exactly:

chunk-040-gallery.js

This keeps your RX Theme organized as an advanced static chunk system.

Leave a Reply

Your email address will not be published. Required fields are marked *