|
4 | 4 | - ensures Try It / Play buttons open modal even if project rendering fails |
5 | 5 | */ |
6 | 6 |
|
7 | | -const prefersReducedMotion = () => window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; |
| 7 | +function prefersReducedMotion() { |
| 8 | + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; |
| 9 | +} |
| 10 | + |
| 11 | +function syncThemeColor(theme) { |
| 12 | + if (!themeColorMeta) return; |
| 13 | + themeColorMeta.setAttribute('content', theme === 'light' ? '#f8fafc' : '#0f172a'); |
| 14 | +} |
| 15 | + |
| 16 | +function updateThemeToggleAria(isLightTheme) { |
| 17 | + themeToggle.setAttribute( |
| 18 | + 'aria-label', |
| 19 | + isLightTheme ? 'Switch to dark mode' : 'Switch to light mode' |
| 20 | + ); |
| 21 | +} |
| 22 | + |
| 23 | +themeToggle.addEventListener('click', () => { |
| 24 | + const currentTheme = html.getAttribute('data-theme'); |
| 25 | + const newTheme = currentTheme === 'light' ? 'dark' : 'light'; |
| 26 | + |
| 27 | + html.setAttribute('data-theme', newTheme); |
| 28 | + localStorage.setItem('theme', newTheme); |
| 29 | + syncThemeColor(newTheme); |
| 30 | + |
| 31 | + themeToggle.innerHTML = |
| 32 | + newTheme === 'light' |
| 33 | + ? '<i class="fas fa-sun" aria-hidden="true"></i>' |
| 34 | + : '<i class="fas fa-moon" aria-hidden="true"></i>'; |
| 35 | + updateThemeToggleAria(newTheme === 'light'); |
| 36 | +}); |
| 37 | + |
| 38 | +const savedTheme = localStorage.getItem('theme') || 'dark'; |
| 39 | +html.setAttribute('data-theme', savedTheme); |
| 40 | +syncThemeColor(savedTheme); |
| 41 | +themeToggle.innerHTML = |
| 42 | + savedTheme === 'light' |
| 43 | + ? '<i class="fas fa-sun" aria-hidden="true"></i>' |
| 44 | + : '<i class="fas fa-moon" aria-hidden="true"></i>'; |
| 45 | +updateThemeToggleAria(savedTheme === 'light'); |
| 46 | + |
| 47 | + |
| 48 | +// Back to Top Button |
| 49 | +const backToTopButton = document.getElementById('backToTop'); |
| 50 | + |
| 51 | +const toggleBackToTopButton = () => { |
| 52 | + backToTopButton.classList.toggle('visible', window.scrollY > 300); |
| 53 | +}; |
| 54 | + |
| 55 | +window.addEventListener('scroll', toggleBackToTopButton, { passive: true }); |
| 56 | +toggleBackToTopButton(); |
| 57 | + |
| 58 | +backToTopButton.addEventListener('click', () => { |
| 59 | + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; |
| 60 | + window.scrollTo({ top: 0, behavior: prefersReducedMotion ? 'auto' : 'smooth' }); |
| 61 | +}); |
| 62 | + |
| 63 | +// Category Filtering |
| 64 | +const tabs = document.querySelectorAll('.tab'); |
| 65 | +const projectCards = document.querySelectorAll('.project-card'); |
| 66 | + |
| 67 | +let recentSearches = JSON.parse(localStorage.getItem('recentSearches') || '[]'); |
| 68 | +let currentSearchQuery = ''; |
| 69 | +let selectedSuggestionIndex = -1; |
| 70 | +let currentCategory = 'all'; |
| 71 | + |
| 72 | +// Category Filtering (tabs) |
| 73 | +const searchInput = document.getElementById('projectSearch'); |
| 74 | +const searchClear = document.getElementById('searchClear'); |
| 75 | +const searchDropdown = document.getElementById('searchDropdown'); |
| 76 | +const searchShortcut = document.getElementById('searchShortcut'); |
| 77 | +const searchLoader = document.getElementById('searchLoader'); |
| 78 | +const emptyState = document.getElementById('emptyState'); |
| 79 | +const resultsList = document.getElementById('resultsList'); |
| 80 | +const resultsSection = document.getElementById('resultsSection'); |
| 81 | +const recentSearchesList = document.getElementById('recentSearchesList'); |
| 82 | +const recentSearchesSection = document.getElementById('recentSearchesSection'); |
| 83 | +const tipsSection = document.getElementById('tipsSection'); |
| 84 | + |
| 85 | +// Debounce function for smooth search performance |
| 86 | +function debounce(func, delay) { |
| 87 | + let timeoutId; |
| 88 | + return function (...args) { |
| 89 | + clearTimeout(timeoutId); |
| 90 | + timeoutId = setTimeout(() => func(...args), delay); |
| 91 | + }; |
| 92 | +} |
| 93 | + |
| 94 | +// Get all matching projects for search query |
| 95 | +function getMatchingProjects(query) { |
| 96 | + if (!query) return []; |
| 97 | + |
| 98 | + const matches = []; |
| 99 | + projectCards.forEach(card => { |
| 100 | + const category = card.getAttribute('data-category'); |
| 101 | + const title = card.querySelector('h3').textContent.toLowerCase(); |
| 102 | + const description = card.querySelector('p').textContent.toLowerCase(); |
| 103 | + const tags = (card.getAttribute('data-tags') || '').toLowerCase(); |
| 104 | + |
| 105 | + const categoryMatch = currentCategory === 'all' || category === currentCategory; |
| 106 | + const searchMatch = title.includes(query) || |
| 107 | + description.includes(query) || |
| 108 | + tags.includes(query); |
| 109 | + |
| 110 | + if (categoryMatch && searchMatch) { |
| 111 | + const project = { |
| 112 | + card: card, |
| 113 | + title: card.querySelector('h3').textContent, |
| 114 | + tags: card.getAttribute('data-tags') || '', |
| 115 | + category: category |
| 116 | + }; |
| 117 | + matches.push(project); |
| 118 | + } |
| 119 | + }); |
| 120 | + |
| 121 | + return matches; |
| 122 | +} |
| 123 | + |
| 124 | +// Render autocomplete suggestions |
| 125 | +function renderSuggestions(query) { |
| 126 | + if (!query) { |
| 127 | + renderRecentSearches(); |
| 128 | + return; |
| 129 | + } |
| 130 | + |
| 131 | + const matches = getMatchingProjects(query); |
| 132 | + |
| 133 | + if (matches.length === 0) { |
| 134 | + resultsSection.style.display = 'none'; |
| 135 | + recentSearchesSection.style.display = 'none'; |
| 136 | + tipsSection.style.display = 'block'; |
| 137 | + return; |
| 138 | + } |
| 139 | + |
| 140 | + resultsList.innerHTML = ''; |
| 141 | + matches.slice(0, 8).forEach((project, index) => { |
| 142 | + const item = document.createElement('div'); |
| 143 | + item.className = 'dropdown-item' + (index === selectedSuggestionIndex ? ' selected' : ''); |
| 144 | + item.innerHTML = ` |
| 145 | + <div class="dropdown-item-icon"> |
| 146 | + ${project.card.querySelector('.card-icon').textContent} |
| 147 | + </div> |
| 148 | + <div class="dropdown-item-text">${highlightMatch(project.title, query)}</div> |
| 149 | + <span class="dropdown-item-tag">${project.category}</span> |
| 150 | + `; |
| 151 | + item.addEventListener('click', () => selectSuggestion(project.title)); |
| 152 | + item.addEventListener('mouseenter', () => { |
| 153 | + selectedSuggestionIndex = index; |
| 154 | + updateSuggestionHighlight(); |
| 155 | + }); |
| 156 | + resultsList.appendChild(item); |
| 157 | + }); |
| 158 | + |
| 159 | + resultsSection.style.display = 'block'; |
| 160 | + recentSearchesSection.style.display = 'none'; |
| 161 | + tipsSection.style.display = 'none'; |
| 162 | + selectedSuggestionIndex = -1; |
| 163 | +} |
| 164 | + |
| 165 | +// Highlight matching text in suggestions |
| 166 | +function highlightMatch(text, query) { |
| 167 | + const parts = text.split(new RegExp(`(${query})`, 'gi')); |
| 168 | + return parts.map(part => |
| 169 | + part.toLowerCase() === query.toLowerCase() |
| 170 | + ? `<mark style="background: rgba(99, 102, 241, 0.3); color: var(--primary-color); font-weight: 600;">${part}</mark>` |
| 171 | + : part |
| 172 | + ).join(''); |
| 173 | +} |
| 174 | + |
| 175 | +// Render recent searches |
| 176 | +function renderRecentSearches() { |
| 177 | + if (!recentSearchesSection) return; |
| 178 | + |
| 179 | + if (recentSearches.length === 0) { |
| 180 | + recentSearchesSection.style.display = 'none'; |
| 181 | + if (tipsSection) tipsSection.style.display = 'block'; |
| 182 | + if (resultsSection) resultsSection.style.display = 'none'; |
| 183 | + return; |
| 184 | + } |
| 185 | + |
| 186 | + recentSearchesList.innerHTML = ''; |
| 187 | + recentSearches.slice(0, 5).forEach((search) => { |
| 188 | + const item = document.createElement('div'); |
| 189 | + item.className = 'dropdown-recent-item'; |
| 190 | + item.innerHTML = ` |
| 191 | + <div class="dropdown-recent-text"> |
| 192 | + <i class="fas fa-history" style="opacity: 0.5; font-size: 0.9rem;"></i> |
| 193 | + <span style="flex: 1; cursor: pointer; color: var(--text-secondary);">${search}</span> |
| 194 | + </div> |
| 195 | + <button class="dropdown-recent-remove" aria-label="Remove search"> |
| 196 | + <i class="fas fa-x"></i> |
| 197 | + </button> |
| 198 | + `; |
| 199 | + |
| 200 | + const textElement = item.querySelector('span'); |
| 201 | + const removeBtn = item.querySelector('.dropdown-recent-remove'); |
| 202 | + |
| 203 | + textElement.addEventListener('click', () => { |
| 204 | + searchInput.value = search; |
| 205 | + currentSearchQuery = search; |
| 206 | + performSearch(); |
| 207 | + closeDropdown(); |
| 208 | + }); |
| 209 | + |
| 210 | + removeBtn.addEventListener('click', (e) => { |
| 211 | + e.stopPropagation(); |
| 212 | + recentSearches = recentSearches.filter(s => s !== search); |
| 213 | + localStorage.setItem('recentSearches', JSON.stringify(recentSearches)); |
| 214 | + renderRecentSearches(); |
| 215 | + }); |
| 216 | + |
| 217 | + recentSearchesList.appendChild(item); |
| 218 | + }); |
| 219 | + |
| 220 | + recentSearchesSection.style.display = 'block'; |
| 221 | + resultsSection.style.display = 'none'; |
| 222 | + tipsSection.style.display = 'block'; |
| 223 | +} |
| 224 | + |
| 225 | +function applyCategoryFilter(category) { |
| 226 | + projectCards.forEach((card) => { |
| 227 | + if (category === 'all' || card.getAttribute('data-category') === category) { |
| 228 | + card.style.display = 'block'; |
| 229 | + if (!prefersReducedMotion()) { |
| 230 | + card.style.animation = 'fadeIn 0.6s ease'; |
| 231 | + } else { |
| 232 | + card.style.animation = 'none'; |
| 233 | + } |
| 234 | + } else { |
| 235 | + card.style.display = 'none'; |
| 236 | + } |
| 237 | + }); |
| 238 | +} |
| 239 | + |
| 240 | +function moveTabFocus(fromIndex, delta) { |
| 241 | + const len = tabs.length; |
| 242 | + const next = (fromIndex + delta + len) % len; |
| 243 | + tabs.forEach((t, i) => { |
| 244 | + const selected = i === next; |
| 245 | + t.classList.toggle('active', selected); |
| 246 | + t.setAttribute('aria-selected', selected ? 'true' : 'false'); |
| 247 | + t.setAttribute('tabindex', selected ? '0' : '-1'); |
| 248 | + }); |
| 249 | + tabs[next].focus(); |
| 250 | + applyCategoryFilter(tabs[next].getAttribute('data-category')); |
| 251 | +} |
| 252 | + |
| 253 | +tabs.forEach((tab, index) => { |
| 254 | + tab.addEventListener('click', () => { |
| 255 | + tabs.forEach((t, i) => { |
| 256 | + const selected = t === tab; |
| 257 | + t.classList.toggle('active', selected); |
| 258 | + t.setAttribute('aria-selected', selected ? 'true' : 'false'); |
| 259 | + t.setAttribute('tabindex', selected ? '0' : '-1'); |
| 260 | + }); |
| 261 | + applyCategoryFilter(tab.getAttribute('data-category')); |
| 262 | + }); |
| 263 | + |
| 264 | + tab.addEventListener('keydown', (e) => { |
| 265 | + let handled = false; |
| 266 | + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { |
| 267 | + moveTabFocus(index, 1); |
| 268 | + handled = true; |
| 269 | + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { |
| 270 | + moveTabFocus(index, -1); |
| 271 | + handled = true; |
| 272 | + } else if (e.key === 'Home') { |
| 273 | + moveTabFocus(index, -index); |
| 274 | + handled = true; |
| 275 | + } else if (e.key === 'End') { |
| 276 | + moveTabFocus(index, tabs.length - 1 - index); |
| 277 | + handled = true; |
| 278 | + } |
| 279 | + if (handled) { |
| 280 | + e.preventDefault(); |
| 281 | + } |
| 282 | + }); |
| 283 | +}); |
| 284 | + |
| 285 | +// Initialize |
| 286 | +renderRecentSearches(); |
| 287 | + |
| 288 | +// Modal Management |
| 289 | +const modal = document.getElementById('projectModal'); |
| 290 | +const modalClose = document.getElementById('modalClose'); |
| 291 | +const modalBody = document.getElementById('modalBody'); |
| 292 | +const modalDialogTitle = document.getElementById('modalDialogTitle'); |
| 293 | + |
| 294 | +let lastFocusedElement = null; |
| 295 | +let modalTabTrapHandler = null; |
8 | 296 |
|
9 | 297 | function getFocusableElements(root) { |
10 | 298 | const selector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; |
|
0 commit comments