1import { Controller } from "@hotwired/stimulus"; 2import EmblaCarousel from "embla-carousel"; 3import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"; 4 5export default class extends Controller { 6 static targets = ["viewport", "prevButton", "nextButton", "dotsContainer", "thumbnailButton"]; 7 static values = { 8 loop: { type: Boolean, default: false }, // Whether to loop the carousel 9 dragFree: { type: Boolean, default: false }, // Whether to allow dragging 10 dots: { type: Boolean, default: true }, // Whether to show dots 11 buttons: { type: Boolean, default: true }, // Whether to show buttons 12 axis: { type: String, default: "x" }, // Axis of the carousel 13 thumbnails: { type: Boolean, default: false }, // Whether to show thumbnails 14 mainCarousel: { type: String, default: "" }, // ID of main carousel for thumbnail sync 15 wheelGestures: { type: Boolean, default: false }, // Whether to enable wheel gestures 16 }; 17 18 connect() { 19 const options = { 20 loop: this.loopValue, 21 dragFree: this.dragFreeValue, 22 axis: this.axisValue, 23 }; 24 if (this.shouldCenterFirstSlide()) { 25 options.align = "center"; 26 options.containScroll = false; 27 } 28 if (this.thumbnailsValue) { 29 // Keep one snap per thumbnail so dot count matches thumbnail count. 30 options.align = "start"; 31 options.containScroll = false; 32 } 33 34 const plugins = []; 35 if (this.wheelGesturesValue) { 36 plugins.push(WheelGesturesPlugin()); 37 } 38 39 this.embla = EmblaCarousel(this.viewportTarget, options, plugins); 40 this.boundHandleKeydown = this.handleKeydown.bind(this); 41 42 if (this.buttonsValue) { 43 this.setupButtons(); 44 } 45 if (this.dotsValue) { 46 this.setupDots(); 47 } 48 if (this.thumbnailsValue) { 49 this.setupThumbnails(); 50 } 51 this.setupKeyboardNavigation(); // Always setup keyboard nav for viewport 52 53 this.embla.on("select", this.updateControls); 54 this.embla.on("reInit", this.updateControls); 55 56 // Safari compatibility: Ensure viewport is ready for focus 57 requestAnimationFrame(() => { 58 if (this.viewportTarget && !this.viewportTarget.getAttribute("aria-label")) { 59 this.viewportTarget.setAttribute("aria-label", "Image carousel, use arrow keys to navigate"); 60 } 61 }); 62 63 // If this is a thumbnail carousel, find and connect to main carousel 64 if (this.mainCarouselValue) { 65 this.connectToMainCarousel(); 66 } 67 68 // Try to establish connections after a delay to ensure all carousels are initialized 69 setTimeout(() => { 70 this.establishConnections(); 71 }, 200); 72 } 73 74 shouldCenterFirstSlide() { 75 return !this.loopValue && !this.thumbnailsValue; 76 } 77 78 disconnect() { 79 if (this.embla) { 80 this.embla.destroy(); 81 } 82 this.teardownKeyboardNavigation(); 83 84 // Clean up carousel connections 85 if (this.thumbnailCarousel) { 86 this.thumbnailCarousel = null; 87 } 88 if (this.mainCarousel) { 89 this.mainCarousel = null; 90 } 91 } 92 93 // --- Thumbnail Carousel Connection --- 94 connectToMainCarousel() { 95 // Use a small delay to ensure the main carousel is fully initialized 96 setTimeout(() => { 97 const mainElement = document.getElementById(this.mainCarouselValue); 98 if (mainElement) { 99 const mainController = this.application.getControllerForElementAndIdentifier(mainElement, "carousel"); 100 if (mainController) { 101 this.mainCarousel = mainController; 102 mainController.thumbnailCarousel = this; 103 104 // Set up sync from thumbnail carousel to main carousel when dragging 105 if (this.embla) { 106 this.embla.on("select", this.syncWithMainCarousel.bind(this)); 107 } 108 109 // Immediately sync thumbnail state with main carousel 110 if (this.hasThumbnailButtonTarget && mainController.embla) { 111 const selectedIndex = mainController.embla.selectedScrollSnap(); 112 this.updateThumbnails(selectedIndex); 113 } 114 } else { 115 console.warn("Main carousel controller not found for ID:", this.mainCarouselValue); 116 } 117 } else { 118 console.warn("Main carousel element not found with ID:", this.mainCarouselValue); 119 } 120 }, 100); 121 } 122 123 establishConnections() { 124 // If this is a thumbnail carousel and not yet connected 125 if (this.mainCarouselValue && !this.mainCarousel) { 126 this.connectToMainCarousel(); 127 } 128 129 // If this is a main carousel, look for any thumbnail carousels that should connect to it
130 if (this.element.id && !this.mainCarouselValue) { 131 const thumbnailCarousels = document.querySelectorAll(`[data-carousel-main-carousel-value="${this.element.id}"]`); 132 thumbnailCarousels.forEach((thumbnailElement) => { 133 const thumbnailController = this.application.getControllerForElementAndIdentifier(thumbnailElement, "carousel"); 134 if (thumbnailController && !thumbnailController.mainCarousel) { 135 thumbnailController.mainCarousel = this; 136 this.thumbnailCarousel = thumbnailController; 137 138 // Set up sync from thumbnail carousel to main carousel when dragging 139 if (thumbnailController.embla) { 140 thumbnailController.embla.on("select", thumbnailController.syncWithMainCarousel.bind(thumbnailController)); 141 } 142 143 // Sync initial state 144 if (thumbnailController.hasThumbnailButtonTarget && this.embla) { 145 const selectedIndex = this.embla.selectedScrollSnap(); 146 thumbnailController.updateThumbnails(selectedIndex); 147 } 148 } 149 }); 150 } 151 } 152 153 // --- Thumbnail Navigation --- 154 setupThumbnails() { 155 if (this.hasThumbnailButtonTarget) { 156 this.thumbnailButtonTargets.forEach((button, index) => { 157 button.addEventListener("click", () => this.onThumbnailClick(index)); 158 button.addEventListener("keydown", this.boundHandleKeydown); 159 }); 160 // Set initial thumbnail state 161 this.updateThumbnails(); 162 } 163 } 164 165 onThumbnailClick(index) { 166 if (!this.embla) return; 167 168 // If this is a thumbnail carousel, sync with main carousel 169 if (this.mainCarousel && this.mainCarousel.embla) { 170 this.mainCarousel.embla.scrollTo(index); 171 // Don't change focus on click, let user continue with thumbnails if they want 172 } else { 173 // This is the main carousel with thumbnails 174 this.embla.scrollTo(index); 175 // Don't change focus on click, let user continue with thumbnails if they want 176 } 177 } 178 179 syncWithMainCarousel() { 180 // This method is called when the thumbnail carousel's selection changes (including drag) 181 if (!this.embla || !this.mainCarousel || !this.mainCarousel.embla) return; 182 183 const selectedIndex = this.embla.selectedScrollSnap(); 184 // Only sync if the main carousel is not already at this index to avoid infinite loops 185 if (this.mainCarousel.embla.selectedScrollSnap() !== selectedIndex) { 186 this.mainCarousel.embla.scrollTo(selectedIndex); 187 } 188 } 189 190 updateThumbnails(selectedIndex = null) { 191 if (!this.hasThumbnailButtonTarget) { 192 return; 193 } 194 195 // If no selectedIndex provided, get it from the appropriate carousel 196 if (selectedIndex === null) { 197 if (this.mainCarousel && this.mainCarousel.embla) { 198 // This is a thumbnail carousel, get index from main carousel 199 selectedIndex = this.mainCarousel.embla.selectedScrollSnap(); 200 } else if (this.embla) { 201 // This is a main carousel with thumbnails 202 selectedIndex = this.embla.selectedScrollSnap(); 203 } else { 204 selectedIndex = 0; 205 } 206 } 207 208 // Scroll the thumbnail carousel to show the active thumbnail 209 if (this.embla && selectedIndex >= 0 && selectedIndex < this.thumbnailButtonTargets.length) { 210 this.embla.scrollTo(selectedIndex); 211 } 212 213 this.thumbnailButtonTargets.forEach((button, index) => { 214 if (index === selectedIndex) { 215 // Active thumbnail styling 216 button.classList.remove("border-neutral-200", "dark:border-neutral-700"); 217 button.classList.add("border-neutral-600", "dark:border-neutral-200"); 218 219 // Update hover colors for active state 220 button.classList.remove("hover:border-neutral-400", "dark:hover:border-neutral-500"); 221 button.classList.add("hover:border-neutral-700", "dark:hover:border-neutral-300"); 222 } else { 223 // Inactive thumbnail styling 224 button.classList.remove("border-neutral-600", "hover:border-neutral-700", "dark:hover:border-neutral-300"); 225 button.classList.add("border-neutral-200", "dark:border-neutral-700"); 226 227 // Reset hover colors for inactive state 228 button.classList.add("hover:border-neutral-400", "dark:hover:border-neutral-500"); 229 } 230 }); 231 } 232 233 // --- Previous/Next Buttons --- 234 setupButtons() { 235 // Guard clauses moved to connect, but keep checks for target presence 236 if (this.hasPrevButtonTarget) { 237 this.prevButtonTarget.addEventListener("click", this.scrollPrev.bind(this), false); 238 this.prevButtonTarget.addEventListener("keydown", this.boundHandleKeydown); 239 } else if (this.buttonsValue) { 240 console.warn("Embla Carousel: 'buttonsValue' is true, but 'prevButtonTarget' is missing."); 241 } 242 243 if (this.hasNextButtonTarget) { 244 this.nextButtonTarget.addEventListener("click", this.scrollNext.bind(this), false); 245 this.nextButtonTarget.addEventListener("keydown", this.boundHandleKeydown); 246 } else if (this.buttonsValue) { 247 console.warn("Embla Carousel: 'buttonsValue' is true, but 'nextButtonTarget' is missing."); 248 } 249 this.updateButtonStates(); 250 } 251 252 scrollPrev() { 253 if (!this.embla) return; 254 this.embla.scrollPrev(); 255 // Transfer focus to viewport for immediate keyboard navigation
256 this.viewportTarget.focus(); 257 } 258 259 scrollNext() { 260 if (!this.embla) return; 261 this.embla.scrollNext(); 262 // Transfer focus to viewport for immediate keyboard navigation 263 this.viewportTarget.focus(); 264 } 265 266 updateButtonStates() { 267 if (!this.embla || !this.buttonsValue) return; // Only run if buttons are enabled 268 269 const activeElement = document.activeElement; 270 let elementToFocus = null; 271 272 const canGoPrev = this.embla.canScrollPrev(); 273 const canGoNext = this.embla.canScrollNext(); 274 275 if (this.hasPrevButtonTarget) { 276 const willBeDisabled = !canGoPrev; 277 if (willBeDisabled && activeElement === this.prevButtonTarget) { 278 if (this.hasNextButtonTarget && canGoNext) { 279 elementToFocus = this.nextButtonTarget; 280 } else { 281 elementToFocus = this.viewportTarget; 282 } 283 } 284 this.prevButtonTarget.disabled = willBeDisabled; 285 } 286 287 if (this.hasNextButtonTarget) { 288 const willBeDisabled = !canGoNext; 289 if (willBeDisabled && activeElement === this.nextButtonTarget) { 290 if (this.hasPrevButtonTarget && canGoPrev) { 291 if (elementToFocus !== this.viewportTarget || activeElement !== this.prevButtonTarget) { 292 elementToFocus = this.prevButtonTarget; 293 } 294 } else if (!elementToFocus) { 295 elementToFocus = this.viewportTarget; 296 } 297 } 298 this.nextButtonTarget.disabled = willBeDisabled; 299 } 300 301 if (elementToFocus && typeof elementToFocus.focus === "function") { 302 elementToFocus.focus(); 303 } 304 } 305 306 // --- Dot Navigation --- 307 setupDots() { 308 // Guard clause moved to connect, but keep checks for target presence 309 if (!this.hasDotsContainerTarget && this.dotsValue) { 310 console.warn("Embla Carousel: 'dotsValue' is true, but 'dotsContainerTarget' is missing."); 311 return; 312 } 313 if (this.hasDotsContainerTarget) { 314 this.generateDots(); 315 this.updateDots(); 316 } 317 } 318 319 generateDots() { 320 if (!this.embla || !this.hasDotsContainerTarget) return; 321 this.dotsContainerTarget.innerHTML = ""; 322 this.embla.scrollSnapList().forEach((_, index) => { 323 const button = document.createElement("button"); 324 button.classList.add( 325 "appearance-none", 326 "bg-transparent", 327 "touch-manipulation", 328 "inline-flex", 329 "no-underline", 330 "border-0", 331 "p-0", 332 "m-0", 333 "size-4", 334 "items-center", 335 "justify-center", 336 "rounded-full", 337 "outline-hidden", 338 "bg-white", 339 "dark:bg-neutral-800", 340 "focus-visible:outline-offset-1.5", 341 "focus-visible:outline-neutral-500", 342 "dark:focus-visible:outline-neutral-200", 343 "hover:bg-neutral-100", 344 "dark:hover:bg-neutral-700/75", 345 ); 346 const dot = document.createElement("div"); 347 dot.classList.add( 348 "w-4", 349 "h-4", 350 "rounded-full", 351 "shadow-[inset_0_0_0_0.15rem_#ccc]", 352 "dark:shadow-[inset_0_0_0_0.15rem_#fff]", 353 ); 354 button.appendChild(dot); 355 button.type = "button"; 356 button.setAttribute("aria-label", `Go to slide ${index + 1}`); 357 button.addEventListener("click", () => this.onDotButtonClick(index)); 358 button.addEventListener("keydown", this.boundHandleKeydown); 359 this.dotsContainerTarget.appendChild(button); 360 }); 361 } 362 363 updateDots() { 364 if (!this.embla || !this.dotsValue || !this.hasDotsContainerTarget) return; // Only run if dots are enabled and target exists 365 366 const activeElement = document.activeElement; 367 const selectedIndex = this.embla.selectedScrollSnap(); 368 let newlySelectedDotButton = null; 369 370 let aDotHadFocus = false; 371 if (activeElement && this.dotsContainerTarget.contains(activeElement)) { 372 aDotHadFocus = Array.from(this.dotsContainerTarget.children).includes(activeElement); 373 } 374 375 Array.from(this.dotsContainerTarget.children).forEach((dotButton, index) => { 376 const dot = dotButton.firstChild; 377 if (index === selectedIndex) { 378 dot.classList.remove("shadow-[inset_0_0_0_0.15rem_#ccc]", "dark:shadow-[inset_0_0_0_0.15rem_#404040]"); 379 dot.classList.add("shadow-[inset_0_0_0_0.15rem_#333]", "dark:shadow-[inset_0_0_0_0.15rem_#fff]"); 380 dotButton.setAttribute("aria-label", `Slide ${index + 1} (current)`); 381 dotButton.setAttribute("aria-current", "true"); 382 if (aDotHadFocus) { 383 newlySelectedDotButton = dotButton; 384 } 385 } else { 386 dot.classList.remove("shadow-[inset_0_0_0_0.15rem_#333]", "dark:shadow-[inset_0_0_0_0.15rem_#fff]"); 387 dot.classList.add("shadow-[inset_0_0_0_0.15rem_#ccc]", "dark:shadow-[inset_0_0_0_0.15rem_#404040]"); 388 dotButton.setAttribute("aria-label", `Go to slide ${index + 1}`); 389 dotButton.removeAttribute("aria-current"); 390 } 391 }); 392 393 if ( 394 newlySelectedDotButton && 395 newlySelectedDotButton !== activeElement && 396 typeof newlySelectedDotButton.focus === "function" 397 ) { 398 newlySelectedDotButton.focus(); 399 } 400 } 401 402 onDotButtonClick(index) { 403 if (!this.embla) return; 404 this.embla.scrollTo(index); 405 // Transfer focus to viewport for immediate keyboard navigation
406 this.viewportTarget.focus(); 407 } 408 409 // --- Combined Controls Update --- 410 updateControls = () => { 411 const selectedIndex = this.embla ? this.embla.selectedScrollSnap() : 0; 412 413 if (this.buttonsValue) { 414 this.updateButtonStates(); 415 } 416 if (this.dotsValue) { 417 this.updateDots(); 418 } 419 420 // Update thumbnails for this carousel (if it has them) 421 if (this.hasThumbnailButtonTarget) { 422 this.updateThumbnails(selectedIndex); 423 } 424 425 // Sync with connected thumbnail carousel 426 if (this.thumbnailCarousel && this.thumbnailCarousel.updateThumbnails) { 427 this.thumbnailCarousel.updateThumbnails(selectedIndex); 428 } 429 }; 430 431 // --- Keyboard Navigation --- 432 setupKeyboardNavigation() { 433 // Make the viewport explicitly focusable and add visual focus styles 434 this.viewportTarget.setAttribute("tabindex", "0"); 435 this.viewportTarget.setAttribute("role", "region"); 436 this.viewportTarget.setAttribute("aria-label", "Carousel"); 437 438 // Add focus styles for better Safari compatibility 439 this.viewportTarget.style.outline = "none"; 440 441 // Set up keyboard event listeners with proper options for Safari 442 this.viewportTarget.addEventListener("keydown", this.boundHandleKeydown, { passive: false }); 443 444 // Add click listener to ensure viewport can receive focus in Safari 445 this.viewportTarget.addEventListener("click", (event) => { 446 if (event.target === this.viewportTarget) { 447 this.viewportTarget.focus(); 448 } 449 }); 450 451 // Safari-specific: Ensure the element can receive focus 452 if (navigator.userAgent.includes("Safari") && !navigator.userAgent.includes("Chrome")) { 453 // Force focus capability in Safari 454 this.viewportTarget.style.webkitUserSelect = "none"; 455 this.viewportTarget.style.userSelect = "none"; 456 } 457 } 458 459 teardownKeyboardNavigation() { 460 this.viewportTarget.removeEventListener("keydown", this.boundHandleKeydown); 461 if (this.buttonsValue) { 462 if (this.hasPrevButtonTarget) { 463 this.prevButtonTarget.removeEventListener("keydown", this.boundHandleKeydown); 464 } 465 if (this.hasNextButtonTarget) { 466 this.nextButtonTarget.removeEventListener("keydown", this.boundHandleKeydown); 467 } 468 } 469 if (this.dotsValue && this.hasDotsContainerTarget) { 470 Array.from(this.dotsContainerTarget.children).forEach((dotButton) => { 471 dotButton.removeEventListener("keydown", this.boundHandleKeydown); 472 }); 473 } 474 if (this.thumbnailsValue && this.hasThumbnailButtonTarget) { 475 this.thumbnailButtonTargets.forEach((button) => { 476 button.removeEventListener("keydown", this.boundHandleKeydown); 477 }); 478 } 479 } 480 481 handleKeydown(event) { 482 if (!this.embla) return; 483 484 // Enhanced Safari compatibility: Check for both event.key and event.keyCode 485 const key = event.key || event.keyCode; 486 const isVertical = this.axisValue === "y"; 487 const fromThumbnailButton = this.isThumbnailButtonEvent(event); 488 489 // Determine which carousel should handle the navigation 490 const targetCarousel = this.getNavigationTarget(); 491 492 switch (key) { 493 case "ArrowLeft": 494 case 37: // Left arrow keyCode for older Safari versions 495 if (isVertical) break; 496 event.preventDefault(); 497 event.stopPropagation(); 498 targetCarousel.scrollPrev(); 499 this.focusViewportAfterThumbnailNavigation(fromThumbnailButton); 500 break; 501 case "ArrowRight": 502 case 39: // Right arrow keyCode for older Safari versions 503 if (isVertical) break; 504 event.preventDefault(); 505 event.stopPropagation(); 506 targetCarousel.scrollNext(); 507 this.focusViewportAfterThumbnailNavigation(fromThumbnailButton); 508 break; 509 case "ArrowUp": 510 case 38: // Up arrow keyCode for older Safari versions 511 if (!isVertical) break; 512 event.preventDefault(); 513 event.stopPropagation(); 514 targetCarousel.scrollPrev(); 515 this.focusViewportAfterThumbnailNavigation(fromThumbnailButton); 516 break; 517 case "ArrowDown": 518 case 40: // Down arrow keyCode for older Safari versions 519 if (!isVertical) break; 520 event.preventDefault(); 521 event.stopPropagation(); 522 targetCarousel.scrollNext(); 523 this.focusViewportAfterThumbnailNavigation(fromThumbnailButton); 524 break; 525 case "Home": 526 case 36: 527 event.preventDefault(); 528 event.stopPropagation(); 529 targetCarousel.scrollTo(0); 530 this.focusViewportAfterThumbnailNavigation(fromThumbnailButton); 531 break; 532 case "End": 533 case 35: 534 event.preventDefault(); 535 event.stopPropagation(); 536 targetCarousel.scrollTo(targetCarousel.scrollSnapList().length - 1); 537 this.focusViewportAfterThumbnailNavigation(fromThumbnailButton); 538 break; 539 } 540 } 541 542 getNavigationTarget() { 543 // If this is a thumbnail carousel, navigation should control the main carousel
544 if (this.mainCarousel && this.mainCarousel.embla) { 545 return this.mainCarousel.embla; 546 } 547 // Otherwise, control this carousel 548 return this.embla; 549 } 550 551 isThumbnailButtonEvent(event) { 552 return ( 553 this.thumbnailsValue && this.hasThumbnailButtonTarget && this.thumbnailButtonTargets.includes(event.currentTarget) 554 ); 555 } 556 557 focusViewportAfterThumbnailNavigation(fromThumbnailButton) { 558 if (!fromThumbnailButton) return; 559 560 if (this.mainCarousel && this.mainCarousel.viewportTarget) { 561 this.mainCarousel.viewportTarget.focus(); 562 } else if (this.viewportTarget) { 563 this.viewportTarget.focus(); 564 } 565 } 566};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.