PageSourceSearch

https://railsblocks.com/assets/controllers/carousel_controller-7e3…28e81452c6acc982061a726d42df0519d517e7f7.js

js railsblocks.com collected 2026-09-25 20:22:52 UTC 20,498 bytes, 566 lines download raw bytes

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.