UNPKG

natura11y

Version:

Natura11y is an open-source design system for creating beautiful web interfaces with a focus on accessible interaction design and front-end development.

440 lines (357 loc) 17 kB
import { delegateEvent } from './utilities/eventDelegation'; import { getFocusableElements } from './utilities/focus'; export default class Track { // Private properties #trackList = document.querySelectorAll('.track'); #scrollTimeout = null; #elementCache = new WeakMap(); // Private methods #getCachedElements(trackElement) { if (!this.#elementCache.has(trackElement)) { this.#elementCache.set(trackElement, { panels: trackElement.querySelector('.track__panels'), pagination: trackElement.querySelector('[data-track-pagination]'), liveRegion: trackElement.querySelector('.liveregion') }); } return this.#elementCache.get(trackElement); } #invalidateCache(trackElement) { this.#elementCache.delete(trackElement); } #getVisiblePanels(trackElement) { return parseInt(getComputedStyle(trackElement).getPropertyValue('--_visible-panels'), 10) || 1; } #toggleControlsVisibility(trackElement, totalPages) { trackElement.classList.toggle('hide-controls', totalPages <= 1); } #setupPagination(trackElement) { const cached = this.#getCachedElements(trackElement); const trackPanels = cached.panels; const paginationContainer = cached.pagination; const visiblePanels = this.#getVisiblePanels(trackPanels); const trackId = trackElement.getAttribute('data-track-id'); // Prevent the panels container from being tabbable trackPanels.setAttribute('tabindex', '-1'); const pages = []; let currentPage = []; Array.from(trackPanels.children).forEach((panel, index) => { const panelId = `${trackId}-panel-${index}`; panel.setAttribute('id', panelId); currentPage.push(panel); if (currentPage.length === visiblePanels || index === trackPanels.children.length - 1) { pages.push([...currentPage]); currentPage = []; } }); trackElement.pages = pages; trackElement.currentPageIndex = 0; if (paginationContainer) { paginationContainer.innerHTML = pages.map((page, i) => ` <li> <button type="button" data-page-index="${i}" aria-label="Go To Page ${i + 1}" ${i === 0 ? 'aria-current="true"' : ''} > <span class="pagination__number"> ${i + 1} </span> </button> </li> `).join(''); } this.#toggleControlsVisibility(trackElement, pages.length); } #updatePagination(trackElement, activeIndex) { const cached = this.#getCachedElements(trackElement); const paginationItems = cached.pagination?.querySelectorAll('[data-page-index]'); if (paginationItems) { paginationItems.forEach((item, index) => { item.classList.toggle('active', index === activeIndex); item.setAttribute('aria-current', index === activeIndex ? 'true' : 'false'); }); } this.#updateLiveRegion(trackElement, activeIndex, trackElement.pages.length); } // Primary method for navigating to a specific page index #navigateToPage(trackElement, pageIndex) { const cached = this.#getCachedElements(trackElement); const trackPanels = cached.panels; const targetPanel = trackElement.pages[pageIndex][0]; trackElement.currentPageIndex = pageIndex; // Set flag to prevent observer interference during programmatic navigation trackElement.isNavigating = true; // Respect user's motion preferences const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; trackPanels.scrollTo({ left: targetPanel.offsetLeft, behavior: prefersReducedMotion ? 'auto' : 'smooth', }); clearTimeout(this.#scrollTimeout); // Update pagination immediately for instant scroll, or after animation for smooth scroll const updateDelay = prefersReducedMotion ? 0 : 300; this.#scrollTimeout = setTimeout(() => { this.#updatePagination(trackElement, pageIndex); // Clear navigation flag after update completes trackElement.isNavigating = false; }, updateDelay); } #navigateToNext(trackElement) { const newIndex = trackElement.currentPageIndex < trackElement.pages.length - 1 ? trackElement.currentPageIndex + 1 : 0; this.#navigateToPage(trackElement, newIndex); } #navigateToPrev(trackElement) { const newIndex = trackElement.currentPageIndex > 0 ? trackElement.currentPageIndex - 1 : trackElement.pages.length - 1; this.#navigateToPage(trackElement, newIndex); } #getPeekingPadding(trackPanels) { const computedStyle = getComputedStyle(trackPanels); const panelPeeking = parseFloat(computedStyle.paddingLeft) || 0; // Assume same for both sides return panelPeeking; } // Page Observer with adjusted rootMargin and threshold #observePages(trackElement, panelPeeking) { const cached = this.#getCachedElements(trackElement); const trackPanels = cached.panels; const pageObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const panelId = entry.target.id; const pageIndex = trackElement.pages.findIndex(page => page.some(panel => panel.id === panelId) ); const updateOnScrollEnd = () => { // Skip observer updates during programmatic navigation if (trackElement.isNavigating) { return; } trackElement.currentPageIndex = pageIndex; this.#updatePagination(trackElement, pageIndex); }; if (pageIndex !== -1) { // Remove existing scroll handler before adding new one if (trackPanels.scrollHandler) { const eventType = trackPanels.scrollEventType || 'scroll'; trackPanels.removeEventListener(eventType, trackPanels.scrollHandler); } // Use addEventListener instead of direct assignment if ('onscrollend' in window) { trackPanels.scrollEventType = 'scrollend'; trackPanels.scrollHandler = updateOnScrollEnd; trackPanels.addEventListener('scrollend', updateOnScrollEnd); } else { const scrollHandler = () => { clearTimeout(this.#scrollTimeout); this.#scrollTimeout = setTimeout(updateOnScrollEnd, 250); }; trackPanels.scrollEventType = 'scroll'; trackPanels.scrollHandler = scrollHandler; trackPanels.addEventListener('scroll', scrollHandler); } } } }); }, { root: trackPanels, threshold: 0.5, rootMargin: `0px -${panelPeeking * 0.5}px`, }); trackElement.pages.forEach(page => { pageObserver.observe(page[0]); }); trackElement.pageObserver = pageObserver; // Save for cleanup } // Tabbing Observer ensures only fully visible panels are tabbable #setupTabbingObserver(trackElement, panelPeeking) { const cached = this.#getCachedElements(trackElement); const trackPanels = cached.panels; const tabbingObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { entry.target.toggleAttribute('inert', !entry.isIntersecting); }); }, { root: trackPanels, threshold: 0.5, rootMargin: `0px -${panelPeeking}px`, }); trackElement.pages.flat().forEach(panel => { tabbingObserver.observe(panel); }); trackElement.tabbingObserver = tabbingObserver; // Save for cleanup } // Keyboard navigation with arrow keys - moves focus to appropriate button after navigation #initKeyboardNavigation(trackElement) { // Navigation buttons: arrow keys navigate carousel and move focus delegateEvent(trackElement, 'keydown', '[data-track-prev], [data-track-next]', (event) => { const prevButton = trackElement.querySelector('[data-track-prev]'); const nextButton = trackElement.querySelector('[data-track-next]'); if (event.code === 'ArrowRight') { event.preventDefault(); this.#navigateToNext(trackElement); nextButton?.focus(); } else if (event.code === 'ArrowLeft') { event.preventDefault(); this.#navigateToPrev(trackElement); prevButton?.focus(); } }); // Panels: arrow keys move focus between visible panels only delegateEvent(trackElement, 'keydown', '.track__panel', (event) => { if (event.code !== 'ArrowRight' && event.code !== 'ArrowLeft') { return; } // Always prevent default to stop arrow keys from doing unexpected things event.preventDefault(); const currentPanel = event.target.closest('.track__panel'); const currentPage = trackElement.pages[trackElement.currentPageIndex]; const currentIndex = currentPage.indexOf(currentPanel); if (currentIndex === -1) return; let targetPanel = null; if (event.code === 'ArrowRight' && currentIndex < currentPage.length - 1) { targetPanel = currentPage[currentIndex + 1]; } else if (event.code === 'ArrowLeft' && currentIndex > 0) { targetPanel = currentPage[currentIndex - 1]; } // Only move focus if there's a valid target panel if (targetPanel) { const focusableElements = getFocusableElements(targetPanel); focusableElements[0]?.focus(); } }); } #setupResizeObserver(trackElement) { const resizeObserver = new ResizeObserver(() => { // Prevent infinite loops by checking if already resetting if (trackElement.isResetting) return; trackElement.isResetting = true; // Invalidate cache and recalculate this.#invalidateCache(trackElement); const cached = this.#getCachedElements(trackElement); const trackPanels = cached.panels; // Reset scroll position trackPanels.scrollLeft = 0; // Clear and rebuild pagination const paginationContainer = cached.pagination; if (paginationContainer) { paginationContainer.innerHTML = ''; } // Cleanup and recreate observers (except resize observer itself) if (trackElement.pageObserver) trackElement.pageObserver.disconnect(); if (trackElement.tabbingObserver) trackElement.tabbingObserver.disconnect(); // Remove scroll handler if (trackPanels.scrollHandler) { const eventType = trackPanels.scrollEventType || 'scroll'; trackPanels.removeEventListener(eventType, trackPanels.scrollHandler); delete trackPanels.scrollHandler; delete trackPanels.scrollEventType; } trackElement.currentPageIndex = 0; const panelPeeking = this.#getPeekingPadding(trackPanels); this.#setupPagination(trackElement); this.#observePages(trackElement, panelPeeking); this.#setupTabbingObserver(trackElement, panelPeeking); trackElement.isResetting = false; }); resizeObserver.observe(trackElement); trackElement.resizeObserver = resizeObserver; } #resetTrackState(trackElement) { // Invalidate cache since we're resetting this.#invalidateCache(trackElement); const cached = this.#getCachedElements(trackElement); const trackPanels = cached.panels; const paginationContainer = cached.pagination; // Reset scroll position trackPanels.scrollLeft = 0; // Clear pagination content if (paginationContainer) { paginationContainer.innerHTML = ''; } // Cleanup observers if (trackElement.pageObserver) trackElement.pageObserver.disconnect(); if (trackElement.tabbingObserver) trackElement.tabbingObserver.disconnect(); // Remove scroll handler if (trackPanels.scrollHandler) { const eventType = trackPanels.scrollEventType || 'scroll'; trackPanels.removeEventListener(eventType, trackPanels.scrollHandler); delete trackPanels.scrollHandler; delete trackPanels.scrollEventType; } trackElement.currentPageIndex = 0; // Compute peeking padding once for both observers const panelPeeking = this.#getPeekingPadding(trackPanels); // Reinitialize after reset this.#setupPagination(trackElement); this.#initLiveRegion(trackElement); this.#observePages(trackElement, panelPeeking); // Peeking observation this.#setupTabbingObserver(trackElement, panelPeeking); // Tabbing management this.#initKeyboardNavigation(trackElement); // Add keyboard navigation // Set up resize observer only if it doesn't exist yet if (!trackElement.resizeObserver) { this.#setupResizeObserver(trackElement); } } #initEventListeners(trackElement) { delegateEvent(trackElement, 'click', '[data-page-index]', (event) => { const target = event.target.closest('[data-page-index]'); if (target) { const pageIndex = parseInt(target.getAttribute('data-page-index')); this.#navigateToPage(trackElement, pageIndex); } }); delegateEvent(trackElement, 'click', '[data-track-prev]', () => { this.#navigateToPrev(trackElement); }); delegateEvent(trackElement, 'click', '[data-track-next]', () => { this.#navigateToNext(trackElement); }); } #initLiveRegion(trackElement) { const cached = this.#getCachedElements(trackElement); let liveRegion = cached.liveRegion; if (!liveRegion) { liveRegion = document.createElement('div'); liveRegion.className = 'liveregion screen-reader-only'; liveRegion.setAttribute('aria-live', 'polite'); liveRegion.setAttribute('aria-atomic', 'true'); trackElement.appendChild(liveRegion); // Update cache with new live region this.#invalidateCache(trackElement); } } #updateLiveRegion(trackElement, activeIndex, totalPages) { const cached = this.#getCachedElements(trackElement); const liveRegion = cached.liveRegion; if (liveRegion) { liveRegion.textContent = `Page ${activeIndex + 1} of ${totalPages}`; } } // Public methods init() { this.#trackList.forEach((trackElement, trackIndex) => { trackElement.setAttribute('data-track-id', `track-${trackIndex}`); this.#resetTrackState(trackElement); this.#initEventListeners(trackElement); }); } destroy(trackElement) { ['pageObserver', 'tabbingObserver', 'resizeObserver'].forEach(observer => { if (trackElement[observer]) trackElement[observer].disconnect(); }); // Clean up scroll handlers const cached = this.#getCachedElements(trackElement); const trackPanels = cached.panels; if (trackPanels?.scrollHandler) { const eventType = trackPanels.scrollEventType || 'scroll'; trackPanels.removeEventListener(eventType, trackPanels.scrollHandler); delete trackPanels.scrollHandler; delete trackPanels.scrollEventType; } clearTimeout(this.#scrollTimeout); } }