// GalleryModal.js - Fullscreen gallery viewer component
|
class GalleryModal {
|
constructor(items, initialIndex = 0) {
|
this.items = items || [];
|
this.currentIndex = initialIndex;
|
this.touchStart = null;
|
this.touchEnd = null;
|
this.minSwipeDistance = 50;
|
this.modal = null;
|
this.keyHandler = null;
|
this.loading = false;
|
}
|
|
/**
|
* Show the gallery modal
|
*/
|
show() {
|
// Create modal if not already created
|
if (!this.modal) {
|
this.modal = this.createModal();
|
document.body.appendChild(this.modal);
|
}
|
|
// Lock body scroll
|
document.body.style.overflow = 'hidden';
|
|
// Bind event handlers
|
this.bindEvents();
|
|
// Show current image
|
this.updateDisplay();
|
|
// Preload adjacent images
|
this.preloadImages();
|
|
// Announce to screen readers
|
this.announceToScreenReaders();
|
}
|
|
/**
|
* Create the modal element
|
*/
|
createModal() {
|
const modal = document.createElement('div');
|
modal.className = 'gallery-modal';
|
modal.setAttribute('role', 'dialog');
|
modal.setAttribute('aria-modal', 'true');
|
modal.setAttribute('aria-label', 'Image Gallery');
|
|
modal.innerHTML = `
|
<div class="gallery-overlay">
|
<button class="gallery-close" aria-label="Close gallery">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
</svg>
|
</button>
|
|
<button class="gallery-nav gallery-prev" aria-label="Previous image">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<polyline points="15 18 9 12 15 6"></polyline>
|
</svg>
|
</button>
|
|
<button class="gallery-nav gallery-next" aria-label="Next image">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<polyline points="9 18 15 12 9 6"></polyline>
|
</svg>
|
</button>
|
|
<div class="gallery-content">
|
<img src="" alt="" class="gallery-image">
|
<details>
|
<summary>DETAILS</summary>
|
<div class="item-info"></div>
|
</details>
|
</div>
|
|
<div class="gallery-favourite"></div>
|
<div class="gallery-counter"></div>
|
|
<div class="live-region" role="status" aria-live="polite" class="screen-reader-text"></div>
|
</div>
|
`;
|
|
// Add styles if they don't exist
|
this.ensureGalleryStyles();
|
|
return modal;
|
}
|
|
/**
|
* Ensure gallery styles are in the document
|
*/
|
ensureGalleryStyles() {
|
if (!document.getElementById('gallery-styles')) {
|
const styles = document.createElement('style');
|
styles.id = 'gallery-styles';
|
styles.textContent = `
|
.gallery-modal {
|
position: fixed;
|
top: 0;
|
left: 0;
|
right: 0;
|
bottom: 0;
|
z-index: 9999;
|
background: rgba(27, 27, 27, 0.9);
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
|
.gallery-overlay {
|
position: relative;
|
width: 100%;
|
height: 100%;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
|
.gallery-content {
|
position: relative;
|
max-width: 100%;
|
max-height: 100%;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
padding: 2rem;
|
}
|
|
.gallery-favourite button.favourite {
|
top: unset;
|
bottom: 1rem;
|
right: 1rem;
|
}
|
|
.gallery-image {
|
max-width: 100%;
|
max-height: calc(100vh - 4rem);
|
object-fit: contain;
|
}
|
|
.gallery-close {
|
position: absolute;
|
top: 1rem;
|
right: 1rem;
|
background: none;
|
border: none;
|
color: white;
|
cursor: pointer;
|
padding: 0.5rem;
|
z-index: 10;
|
transition: color 0.3s ease;
|
}
|
|
.gallery-close:hover {
|
color: #FF0080;
|
}
|
|
.gallery-nav {
|
position: absolute;
|
top: 50%;
|
transform: translateY(-50%);
|
background: none;
|
border: none;
|
color: white;
|
cursor: pointer;
|
padding: 1rem;
|
transition: color 0.3s ease;
|
}
|
|
.gallery-nav:hover {
|
color: #FF0080;
|
}
|
|
.gallery-prev {
|
left: 1rem;
|
}
|
|
.gallery-next {
|
right: 1rem;
|
}
|
|
.gallery-counter {
|
position: absolute;
|
top: 1rem;
|
left: 1rem;
|
color: white;
|
font-size: 0.875rem;
|
}
|
|
.gallery-content details {
|
position: absolute;
|
bottom: 1rem;
|
left: 2rem;
|
width: calc(100% - 4rem);
|
padding: 0;
|
}
|
|
.gallery-content details summary {
|
background-color: rgba(249,249,249,.2);
|
backdrop-filter: blur(5px);
|
border: none;
|
cursor: pointer;
|
}
|
|
.gallery-content details:hover summary,
|
.gallery-content details[open] summary {
|
background-color: rgba(255,0,128,.4);
|
backdrop-filter: blur(5px);
|
}
|
|
.gallery-content .item-info {
|
background-color: rgba(249,249,249,.6);
|
backdrop-filter: blur(5px);
|
}
|
`;
|
document.head.appendChild(styles);
|
}
|
}
|
|
/**
|
* Bind event handlers
|
*/
|
bindEvents() {
|
// Close button
|
this.modal.querySelector('.gallery-close').addEventListener('click', () => this.close());
|
|
// Navigation buttons
|
const prevBtn = this.modal.querySelector('.gallery-prev');
|
const nextBtn = this.modal.querySelector('.gallery-next');
|
|
prevBtn.addEventListener('click', () => this.navigate(-1));
|
nextBtn.addEventListener('click', () => this.navigate(1));
|
|
// Keyboard navigation
|
this.keyHandler = (e) => {
|
switch (e.key) {
|
case 'ArrowLeft':
|
this.navigate(-1);
|
break;
|
case 'ArrowRight':
|
this.navigate(1);
|
break;
|
case 'Escape':
|
this.close();
|
break;
|
}
|
};
|
document.addEventListener('keydown', this.keyHandler);
|
|
// Touch events
|
this.modal.addEventListener('touchstart', (e) => {
|
this.touchStart = e.touches[0].clientX;
|
});
|
|
this.modal.addEventListener('touchmove', (e) => {
|
this.touchEnd = e.touches[0].clientX;
|
});
|
|
this.modal.addEventListener('touchend', () => {
|
if (!this.touchStart || !this.touchEnd) return;
|
|
const distance = this.touchStart - this.touchEnd;
|
const isLeftSwipe = distance > this.minSwipeDistance;
|
const isRightSwipe = distance < -this.minSwipeDistance;
|
|
if (isLeftSwipe) {
|
this.navigate(1);
|
} else if (isRightSwipe) {
|
this.navigate(-1);
|
}
|
|
this.touchStart = null;
|
this.touchEnd = null;
|
});
|
}
|
|
/**
|
* Navigate to previous/next image
|
*/
|
async navigate(direction) {
|
const newIndex = this.currentIndex + direction;
|
|
// Check if out of bounds
|
if (newIndex < 0 || newIndex >= this.items.length) {
|
this.announceNavigation(direction > 0 ? 'last' : 'first');
|
return;
|
}
|
|
// Update current index
|
this.currentIndex = newIndex;
|
|
// Update display
|
this.updateDisplay();
|
|
// Preload adjacent images
|
this.preloadImages();
|
|
// Announce to screen readers
|
this.announceNavigation(direction > 0 ? 'next' : 'previous');
|
|
// Trigger onNavigate callback if provided
|
if (this.onNavigate) {
|
this.onNavigate(this.currentIndex);
|
}
|
|
// Check if near the end and can load more
|
if (direction > 0 && newIndex >= this.items.length - 3 && this.onLoadMore) {
|
if (!this.loading) {
|
this.loading = true;
|
const loadedMore = await this.onLoadMore();
|
this.loading = false;
|
|
if (loadedMore) {
|
// Update navigation buttons
|
this.updateNavigationButtons();
|
}
|
}
|
}
|
}
|
|
/**
|
* Preload adjacent images
|
*/
|
preloadImages() {
|
// Preload current, previous and next images
|
[-1, 0, 1].forEach(offset => {
|
const index = this.currentIndex + offset;
|
if (index >= 0 && index < this.items.length) {
|
const img = new Image();
|
const item = this.items[index];
|
|
if (window.innerWidth < 1000) {
|
img.src = item.large || item.src;
|
} else {
|
img.src = item.full || item.src;
|
}
|
}
|
});
|
}
|
|
/**
|
* Update display with current image
|
*/
|
updateDisplay() {
|
const item = this.items[this.currentIndex];
|
if (!item) return;
|
|
// Get elements
|
const favourite = this.modal.querySelector('.gallery-favourite');
|
const image = this.modal.querySelector('.gallery-image');
|
const counter = this.modal.querySelector('.gallery-counter');
|
const info = this.modal.querySelector('.item-info');
|
|
// Update image
|
image.src = window.innerWidth < 1000 ?
|
(item.large || item.src) :
|
(item.full || item.src);
|
|
image.alt = item.alt || '';
|
|
// Update favourite button
|
if (favourite && item.fav) {
|
favourite.innerHTML = '';
|
favourite.appendChild(item.fav.cloneNode(true));
|
}
|
|
// Update info
|
if (info && item.info) {
|
info.innerHTML = '';
|
const clone = item.info.cloneNode(true);
|
info.appendChild(clone);
|
}
|
|
// Update counter
|
counter.textContent = `${this.currentIndex + 1} / ${this.items.length}`;
|
|
// Update navigation buttons
|
this.updateNavigationButtons();
|
}
|
|
/**
|
* Update navigation button visibility
|
*/
|
updateNavigationButtons() {
|
const prevBtn = this.modal.querySelector('.gallery-prev');
|
const nextBtn = this.modal.querySelector('.gallery-next');
|
|
prevBtn.style.display = this.currentIndex > 0 ? '' : 'none';
|
nextBtn.style.display = this.currentIndex < this.items.length - 1 ? '' : 'none';
|
}
|
|
/**
|
* Close the gallery
|
*/
|
close() {
|
// Remove event listeners
|
document.removeEventListener('keydown', this.keyHandler);
|
|
// Remove modal from DOM
|
if (this.modal && this.modal.parentNode) {
|
document.body.removeChild(this.modal);
|
}
|
|
// Restore body scroll
|
document.body.style.overflow = '';
|
|
// Reset state
|
this.modal = null;
|
this.keyHandler = null;
|
|
// Dispatch close event
|
document.dispatchEvent(new CustomEvent('galleryClose'));
|
|
// Call onClose callback if provided
|
if (this.onClose) {
|
this.onClose();
|
}
|
}
|
|
/**
|
* Announce to screen readers
|
*/
|
announceToScreenReaders() {
|
const liveRegion = this.modal.querySelector('.live-region');
|
if (liveRegion) {
|
liveRegion.textContent = `Image ${this.currentIndex + 1} of ${this.items.length}. Use arrow keys to navigate.`;
|
}
|
}
|
|
|
/**
|
* Update gallery items
|
* @param {Array} newItems - New gallery items
|
*/
|
updateItems(newItems) {
|
// Store original current index and item
|
const currentItem = this.items[this.currentIndex];
|
|
// Update items array
|
this.items = newItems;
|
|
// Try to keep the same item selected
|
if (currentItem) {
|
// Find the same item in the new array by matching source
|
const newIndex = this.items.findIndex(item =>
|
item.full === currentItem.full ||
|
item.large === currentItem.large
|
);
|
|
if (newIndex !== -1) {
|
this.currentIndex = newIndex;
|
}
|
}
|
|
// Update navigation buttons
|
this.updateNavigationButtons();
|
}
|
|
|
/**
|
* Set callbacks
|
* @param {Object} callbacks - Callback functions
|
*/
|
setCallbacks(callbacks = {}) {
|
const { onClose, onLoadMore, onNavigate } = callbacks;
|
|
this.onClose = onClose;
|
this.onLoadMore = onLoadMore;
|
this.onNavigate = onNavigate;
|
}
|
|
|
/**
|
* Ensure gallery is accessible
|
*/
|
setupAccessibility() {
|
if (!this.modal) return;
|
|
// Add ARIA attributes
|
this.modal.setAttribute('role', 'dialog');
|
this.modal.setAttribute('aria-modal', 'true');
|
this.modal.setAttribute('aria-label', 'Image Gallery');
|
|
// Create live region for announcements
|
this.liveRegion = document.createElement('div');
|
this.liveRegion.setAttribute('aria-live', 'polite');
|
this.liveRegion.setAttribute('role', 'status');
|
this.liveRegion.className = 'screen-reader-text';
|
this.modal.querySelector('.gallery-overlay').appendChild(this.liveRegion);
|
|
// Announce initial state
|
this.announceToScreenReader(`Image ${this.currentIndex + 1} of ${this.items.length}. Use arrow keys to navigate.`);
|
|
// Set up focus trap
|
const focusableElements = this.modal.querySelectorAll(
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
);
|
if (focusableElements.length) {
|
focusableElements[0].focus();
|
this.trapFocus(this.modal);
|
}
|
}
|
|
/**
|
* Announce to screen readers
|
*/
|
announceToScreenReader(message) {
|
if (!this.liveRegion) return;
|
this.liveRegion.textContent = message;
|
}
|
|
/**
|
* Trap focus within gallery modal
|
*/
|
trapFocus(element) {
|
const focusableElements = element.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
const firstFocusable = focusableElements[0];
|
const lastFocusable = focusableElements[focusableElements.length - 1];
|
|
element.addEventListener('keydown', function(e) {
|
if (e.key === 'Tab') {
|
// Shift+Tab on first element focuses last element
|
if (e.shiftKey && document.activeElement === firstFocusable) {
|
lastFocusable.focus();
|
e.preventDefault();
|
}
|
// Tab on last element focuses first element
|
else if (!e.shiftKey && document.activeElement === lastFocusable) {
|
firstFocusable.focus();
|
e.preventDefault();
|
}
|
}
|
});
|
}
|
/**
|
* Announce navigation to screen readers
|
*/
|
announceNavigation(direction) {
|
if (!this.liveRegion) return;
|
|
if (direction === 'first') {
|
this.liveRegion.textContent = 'At first image';
|
} else if (direction === 'last') {
|
this.liveRegion.textContent = 'At last image';
|
} else {
|
this.liveRegion.textContent = `Image ${this.currentIndex + 1} of ${this.items.length}`;
|
}
|
}
|
|
/**
|
* Set callbacks
|
*/
|
setCallbacks(callbacks = {}) {
|
const { onClose, onLoadMore, onNavigate } = callbacks;
|
|
if (onClose) this.onClose = onClose;
|
if (onLoadMore) this.onLoadMore = onLoadMore;
|
if (onNavigate) this.onNavigate = onNavigate;
|
}
|
}
|
|
export default GalleryModal;
|