// 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 = `
`; // 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;