PageSourceSearch

https://railsblocks.com/assets/controllers/tabs_controller-4cdc2eb…d4bb69e9a3091ae1b1eebc0a9510073a5476a872.js

js railsblocks.com collected 2026-09-25 20:24:24 UTC 24,317 bytes, 767 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2
3export default class extends Controller {
4  static classes = ["activeTab", "inactiveTab"];
5  static targets = ["tab", "panel", "select", "tabList", "progressBar", "template"];
6  static values = {
7    index: 0, // Index of the tab to show on load
8    updateAnchor: Boolean, // Whether to update the anchor
9    scrollToAnchor: Boolean, // Whether to scroll to the anchor
10    scrollActiveTabIntoView: Boolean, // Whether to scroll the active tab into view
11    autoSwitch: Boolean, // Whether to auto-switch tabs
12    autoSwitchInterval: { type: Number, default: 5000 }, // Interval in ms for auto-switching
13    pauseOnHover: { type: Boolean, default: true }, // Whether to pause auto-switching when hovering over a tab
14    showProgressBar: Boolean, // Whether to show the progress bar
15    lazyLoad: { type: Boolean, default: false }, // Whether to lazy load the tab content
16    turboFrameSrc: { type: String, default: "" }, // URL for the turbo frame
17    arrowFocusOnly: { type: Boolean, default: false }, // Whether arrow keys only change focus without changing content (Depending on if you prefer to have Tabs change content or just focus | based on tips from https://www.w3.org/WAI/ARIA/apg/patterns/tabs#examples)
18  };
19
20  initialize() {
21    // Track initial page load to avoid adding anchor when none exists
22    this.initialLoad = true;
23    this.hadInitialAnchor = !!this.anchor;
24
25    if (this.updateAnchorValue && this.anchor) {
26      this.indexValue = this.tabTargets.findIndex((tab) => tab.id === this.anchor);
27    }
28
29    // Initialize auto-switch properties
30    this.autoSwitchTimer = null;
31    this.startTime = null;
32    this.isPaused = false;
33    this.remainingTime = 0;
34    this.currentProgress = 0;
35
36    // Initialize lazy loading tracking
37    this.loadedPanels = new Set();
38
39    // Initialize focus tracking for arrow-focus-only mode
40    this.focusedTabIndex = this.indexValue;
41  }
42
43  async connect() {
44    if (this.tabsCount === 0) return;
45
46    this.indexValue = this.normalizedEnabledIndex(this.indexValue);
47    this.focusedTabIndex = this.indexValue;
48
49    await this.showTab();
50    this.setInitialFocus();
51    this.revealTabs();
52    this.addKeyboardEventListeners();
53
54    // Start auto-switching if enabled
55    if (this.autoSwitchValue) {
56      this.startAutoSwitch();
57      this.addHoverEventListeners();
58    }
59
60    // Mark initial load as complete
61    this.initialLoad = false;
62  }
63
64  disconnect() {
65    this.removeKeyboardEventListeners();
66    this.removeHoverEventListeners();
67    this.stopAutoSwitch();
68  }
69
70  addKeyboardEventListeners() {
71    this.keydownHandler = this.handleKeydown.bind(this);
72    this.focusHandler = this.handleFocus.bind(this);
73    this.tabTargets.forEach((tab) => {
74      tab.addEventListener("keydown", this.keydownHandler);
75      tab.addEventListener("focus", this.focusHandler);
76    });
77  }
78
79  removeKeyboardEventListeners() {
80    if (this.keydownHandler) {
81      this.tabTargets.forEach((tab) => {
82        tab.removeEventListener("keydown", this.keydownHandler);
83        tab.removeEventListener("focus", this.focusHandler);
84      });
85    }
86  }
87
88  addHoverEventListeners() {
89    if (this.pauseOnHoverValue) {
90      this.mouseEnterHandler = this.pauseAutoSwitch.bind(this);
91      this.mouseLeaveHandler = this.resumeAutoSwitch.bind(this);
92      this.focusHandler = this.pauseAutoSwitch.bind(this);
93      this.blurHandler = this.resumeAutoSwitch.bind(this);
94
95      this.element.addEventListener("mouseenter", this.mouseEnterHandler);
96      this.element.addEventListener("mouseleave", this.mouseLeaveHandler);
97      this.element.addEventListener("focusin", this.focusHandler);
98      this.element.addEventListener("focusout", this.blurHandler);
99    }
100  }
101
102  removeHoverEventListeners() {
103    if (this.mouseEnterHandler) {
104      this.element.removeEventListener("mouseenter", this.mouseEnterHandler);
105      this.element.removeEventListener("mouseleave", this.mouseLeaveHandler);
106      this.element.removeEventListener("focusin", this.focusHandler);
107      this.element.removeEventListener("focusout", this.blurHandler);
108    }
109  }
110
111  handleFocus(event) {
112    // When a tab receives focus, sync the focusedTabIndex with the tab that actually has focus
113    const focusedTab = event.target.closest('[data-tabs-target="tab"]');
114    if (focusedTab && !this.isTabDisabled(focusedTab)) {
115      const tabIndex = this.tabTargets.indexOf(focusedTab);
116      if (tabIndex !== -1) {
117        this.focusedTabIndex = tabIndex;
118      }
119    }
120  }
121
122  handleKeydown(event) {
123    // Only handle keyboard events when focus is on a tab
124    if (!this.tabTargets.includes(event.target) && !event.target.closest('[data-tabs-target="tab"]')) {
125      return;
126    }
127
128    const orientation = this.getOrientation();
129
130    switch (event.key) {
131      case "ArrowLeft":
132        // Handle left arrow based on orientation
133        if (orientation === "vertical") {
134          // For vertical tabs, left/right arrows should not be handled by tabs
135          return;
136        }
137        event.preventDefault();
138        if (this.arrowFocusOnlyValue) {
139          this.previousTabFocusOnly();
140        } else {
141          this.previousTab();
142        }
143        break;
144      case "ArrowRight":
145        // Handle right arrow based on orientation
146        if (orientation === "vertical") {
147          // For vertical tabs, left/right arrows should not be handled by tabs
148          return;
149        }
150        event.preventDefault();
151        if (this.arrowFocusOnlyValue) {
152          this.nextTabFocusOnly();
153        } else {
154          this.nextTab();
155        }
156        break;
157      case "ArrowUp":
158        // Handle up arrow based on orientation
159        if (orientation === "horizontal") {
160          // For horizontal tabs, up/down arrows should not be handled by tabs
161          return;
162        }
163        event.preventDefault();
164        if (this.arrowFocusOnlyValue) {
165          this.previousTabFocusOnly();
166        } else {
167          this.previousTab();
168        }
169        break;
170      case "ArrowDown":
171        // Handle down arrow based on orientation
172        if (orientation === "horizontal") {
173          // For horizontal tabs, up/down arrows should not be handled by tabs
174          return;
175        }
176        event.preventDefault();
177        if (this.arrowFocusOnlyValue) {
178          this.nextTabFocusOnly();
179        } else {
180          this.nextTab();
181        }
182        break;
183      case "Home":
184        event.preventDefault();
185        if (this.arrowFocusOnlyValue) {
186          this.firstTabFocusOnly();
187        } else {
188          this.firstTab();
189        }
190        break;
191      case "End":
192        event.preventDefault();
193        if (this.arrowFocusOnlyValue) {
194          this.lastTabFocusOnly();
195        } else {
196          this.lastTab();
197        }
198        break;
199    }
200  }
201
202  // Changes to the clicked tab
203  change(event) {
204    let targetIndex;
205
206    if (event.currentTarget.tagName === "SELECT") {
207      targetIndex = event.currentTarget.selectedIndex;
208
209      // If target specifies an index, use that
210    } else if (event.currentTarget.dataset.index) {
211      targetIndex = parseInt(event.currentTarget.dataset.index);
212
213      // If target specifies an id, use that
214    } else if (event.currentTarget.dataset.id) {
215      targetIndex = this.tabTargets.findIndex((tab) => tab.id === event.currentTarget.dataset.id);
216
217      // Otherwise, use the index of the current target
218    } else {
219      if (this.isTabDisabled(event.currentTarget)) return;
220      targetIndex = this.tabTargets.indexOf(event.currentTarget);
221    }
222
223    if (!Number.isInteger(targetIndex) || targetIndex < 0 || targetIndex >= this.tabsCount) return;
224
225    const enabledIndex = this.normalizedEnabledIndex(targetIndex);
226    if (enabledIndex === -1 || enabledIndex === this.indexValue) return;
227
228    this.indexValue = enabledIndex;
229
230    // Sync the focused tab index with the active tab
231    this.focusedTabIndex = this.indexValue;
232
233    // Set focus to the newly active tab
234    this.setFocusToActiveTab();
235
236    // Restart auto-switch timer when user manually changes tabs
237    if (this.autoSwitchValue) {
238      this.restartAutoSwitch();
239    }
240  }
241
242  nextTab(shouldFocus = true) {
243    const nextIndex = this.nextEnabledTabIndex(this.indexValue);
244    if (nextIndex === -1) return;
245    this.indexValue = nextIndex;
246
247    // Sync the focused tab index
248    this.focusedTabIndex = this.indexValue;
249
250    if (shouldFocus) {
251      this.setFocusToActiveTab();
252      // Restart auto-switch timer when user manually changes tabs via keyboard
253      if (this.autoSwitchValue) {
254        this.restartAutoSwitch();
255      }
256    }
257  }
258
259  previousTab() {
260    const previousIndex = this.previousEnabledTabIndex(this.indexValue);
261    if (previousIndex === -1) return;
262    this.indexValue = previousIndex;
263
264    // Sync the focused tab index
265    this.focusedTabIndex = this.indexValue;
266
267    this.setFocusToActiveTab();
268    // Restart auto-switch timer when user manually changes tabs via keyboard
269    if (this.autoSwitchValue) {
270      this.restartAutoSwitch();
271    }
272  }
273
274  firstTab() {
275    const firstEnabledIndex = this.firstEnabledTabIndex();
276    if (firstEnabledIndex === -1) return;
277    this.indexValue = firstEnabledIndex;
278
279    // Sync the focused tab index
280    this.focusedTabIndex = this.indexValue;
281
282    this.setFocusToActiveTab();
283    // Restart auto-switch timer when user manually changes tabs via keyboard
284    if (this.autoSwitchValue) {
285      this.restartAutoSwitch();
286    }
287  }
288
289  lastTab() {
290    const lastEnabledIndex = this.lastEnabledTabIndex();
291    if (lastEnabledIndex === -1) return;
292    this.indexValue = lastEnabledIndex;
293
294    // Sync the focused tab index
295    this.focusedTabIndex = this.indexValue;
296
297    this.setFocusToActiveTab();
298    // Restart auto-switch timer when user manually changes tabs via keyboard
299    if (this.autoSwitchValue) {
300      this.restartAutoSwitch();
301    }
302  }
303
304  // Focus-only navigation methods (don't change content)
305  nextTabFocusOnly() {
306    const nextIndex = this.nextEnabledTabIndex(this.focusedTabIndex);
307    if (nextIndex === -1) return;
308    this.focusedTabIndex = nextIndex;
309    this.setFocusToTab(this.focusedTabIndex);
310  }
311
312  previousTabFocusOnly() {
313    const previousIndex = this.previousEnabledTabIndex(this.focusedTabIndex);
314    if (previousIndex === -1) return;
315    this.focusedTabIndex = previousIndex;
316    this.setFocusToTab(this.focusedTabIndex);
317  }
318
319  firstTabFocusOnly() {
320    const firstEnabledIndex = this.firstEnabledTabIndex();
321    if (firstEnabledIndex === -1) return;
322    this.focusedTabIndex = firstEnabledIndex;
323    this.setFocusToTab(this.focusedTabIndex);
324  }
325
326  lastTabFocusOnly() {
327    const lastEnabledIndex = this.lastEnabledTabIndex();
328    if (lastEnabledIndex === -1) return;
329    this.focusedTabIndex = lastEnabledIndex;
330    this.setFocusToTab(this.focusedTabIndex);
331  }
332
333  // Auto-switch methods
334  startAutoSwitch() {
335    if (!this.autoSwitchValue || this.tabsCount <= 1) return;
336
337    this.stopAutoSwitch(); // Clear any existing timers
338    this.startTime = Date.now();
339    this.remainingTime = this.autoSwitchIntervalValue;
340    this.currentProgress = 0; // Always start from 0 for a new cycle
341    this.isPaused = false; // Ensure we're not in paused state
342
343    this.autoSwitchTimer = setTimeout(() => {
344      this.nextTab(false); // Don't focus when auto-switching
345      this.startAutoSwitch(); // Restart for next cycle
346    }, this.autoSwitchIntervalValue);
347
348    if (this.showProgressBarValue) {
349      this.startProgressBar();
350    }
351  }
352
353  stopAutoSwitch() {
354    if (this.autoSwitchTimer) {
355      clearTimeout(this.autoSwitchTimer);
356      this.autoSwitchTimer = null;
357    }
358    this.stopProgressBar();
359    this.resetProgressBar();
360  }
361
362  stopProgressBar() {
363    if (this.hasProgressBarTarget) {
364      // Stop the transition by removing it and keeping current width
365      const currentWidth = this.progressBarTarget.getBoundingClientRect().width;
366      const containerWidth = this.progressBarTarget.parentElement.getBoundingClientRect().width;
367      const currentProgress = (currentWidth / containerWidth) * 100;
368
369      this.progressBarTarget.style.transition = "none";
370      this.progressBarTarget.style.width = `${currentProgress}%`;
371      this.currentProgress = currentProgress;
372    }
373  }
374
375  pauseAutoSwitch() {
376    if (!this.autoSwitchValue || this.isPaused) return;
377
378    this.isPaused = true;
379    const elapsed = Date.now() - this.startTime;
380    this.remainingTime = Math.max(0, this.autoSwitchIntervalValue - elapsed);
381
382    // Stop progress bar and calculate remaining time
383    if (this.showProgressBarValue) {
384      this.stopProgressBar();
385    }
386
387    if (this.autoSwitchTimer) {
388      clearTimeout(this.autoSwitchTimer);
389      this.autoSwitchTimer = null;
390    }
391  }
392
393  resumeAutoSwitch() {
394    if (!this.autoSwitchValue || !this.isPaused) return;
395
396    this.isPaused = false;
397    this.startTime = Date.now() - (this.autoSwitchIntervalValue - this.remainingTime); // Adjust start time to account for elapsed time
398
399    this.autoSwitchTimer = setTimeout(() => {
400      this.nextTab(false); // Don't focus when auto-switching
401      this.startAutoSwitch(); // Restart for next cycle
402    }, this.remainingTime);
403
404    if (this.showProgressBarValue) {
405      this.resumeProgressBar();
406    }
407  }
408
409  restartAutoSwitch() {
410    if (!this.autoSwitchValue) return;
411
412    const wasAlreadyPaused = this.isPaused;
413    this.stopAutoSwitch();
414    this.currentProgress = 0;
415
416    if (wasAlreadyPaused) {
417      // If we were paused (e.g., due to hover), stay paused after manual tab change
418      this.isPaused = true;
419      this.remainingTime = this.autoSwitchIntervalValue;
420      // Don't start the timer, just reset the progress bar
421      if (this.showProgressBarValue) {
422        this.resetProgressBar();
423      }
424    } else {
425      // If we weren't paused, restart normally
426      this.isPaused = false;
427      this.startAutoSwitch();
428    }
429  }
430
431  // Progress bar methods
432  startProgressBar() {
433    if (!this.hasProgressBarTarget) return;
434
435    // Always start from 0% for a new cycle
436    this.progressBarTarget.style.transition = "none";
437    this.progressBarTarget.style.width = "0%";
438
439    // Force a reflow to ensure the position is set
440    this.progressBarTarget.offsetHeight;
441
442    // Set up the linear transition and animate to 100%
443    this.progressBarTarget.style.transition = `width ${this.autoSwitchIntervalValue}ms linear`;
444    this.progressBarTarget.style.width = "100%";
445  }
446
447  resumeProgressBar() {
448    if (!this.hasProgressBarTarget) return;
449
450    // Continue from current progress
451    const startProgress = this.currentProgress || 0;
452
453    // Set starting position without transition
454    this.progressBarTarget.style.transition = "none";
455    this.progressBarTarget.style.width = `${startProgress}%`;
456
457    // Force a reflow to ensure the position is set
458    this.progressBarTarget.offsetHeight;
459
460    // Set up the linear transition for remaining time
461    this.progressBarTarget.style.transition = `width ${this.remainingTime}ms linear`;
462    this.progressBarTarget.style.width = "100%";
463  }
464
465  updateProgressBar(progress) {
466    // This method is no longer needed with CSS transitions, but keeping for compatibility
467    if (this.hasProgressBarTarget && typeof progress === "number") {
468      this.progressBarTarget.style.transition = "none";
469      this.progressBarTarget.style.width = `${progress}%`;
470    }
471  }
472
473  resetProgressBar() {
474    if (this.hasProgressBarTarget) {
475      this.progressBarTarget.style.transition = "none";
476      this.progressBarTarget.style.width = "0%";
477      this.currentProgress = 0;
478    }
479  }
480
481  async indexValueChanged() {
482    if (this.tabsCount === 0) return;
483
484    const normalizedIndex = this.normalizedEnabledIndex(this.indexValue);
485    if (normalizedIndex === -1) return;
486
487    if (normalizedIndex !== this.indexValue) {
488      this.indexValue = normalizedIndex;
489      return;
490    }
491
492    const activeTab = this.tabTargets[this.indexValue];
493    if (!activeTab) return;
494
495    await this.showTab();
496    this.dispatch("tab-change", {
497      target: activeTab,
498      detail: {
499        activeIndex: this.indexValue,
500      },
501    });
502
503    // Update URL with the tab ID if it has one
504    // Only update URL if:
505    // 1. Not during initial load, OR
506    // 2. There was an anchor in the URL when the page loaded
507    if (this.updateAnchorValue && (!this.initialLoad || this.hadInitialAnchor)) {
508      const newTabId = activeTab.id;
509      if (newTabId) {
510        if (this.scrollToAnchorValue) {
511          location.hash = newTabId;
512        } else {
513          const currentUrl = window.location.href;
514          const newUrl = currentUrl.split("#")[0] + "#" + newTabId;
515          if (typeof Turbo !== "undefined") {
516            Turbo.navigator.history.replace(new URL(newUrl));
517          } else {
518            history.replaceState({}, document.title, newUrl);
519          }
520        }
521      }
522    }
523  }
524
525  async showTab() {
526    this.panelTargets.forEach((panel, index) => {
527      const tab = this.tabTargets[index];
528      if (!tab) return;
529      const disabled = this.isTabDisabled(tab);
530
531      if (!disabled && index === this.indexValue) {
532        // Show active panel
533        panel.classList.remove("hidden");
534
535        // Set active tab attributes and classes
536        tab.setAttribute("aria-selected", "true");
537        tab.setAttribute("tabindex", "0");
538        tab.dataset.active = "true";
539
540        if (this.hasInactiveTabClass) {
541          tab.classList.remove(...this.inactiveTabClasses);
542        }
543        if (this.hasActiveTabClass) {
544          tab.classList.add(...this.activeTabClasses);
545        }
546      } else {
547        // Hide inactive panels
548        panel.classList.add("hidden");
549
550        // Set inactive tab attributes and classes
551        tab.setAttribute("aria-selected", "false");
552        tab.setAttribute("tabindex", "-1");
553        delete tab.dataset.active;
554
555        if (this.hasActiveTabClass) {
556          tab.classList.remove(...this.activeTabClasses);
557        }
558        if (this.hasInactiveTabClass) {
559          tab.classList.add(...this.inactiveTabClasses);
560        }
561      }
562    });
563
564    // Update select element if present
565    if (this.hasSelectTarget) {
566      this.selectTarget.selectedIndex = this.indexValue;
567    }
568
569    // Scroll active tab into view if needed
570    if (this.scrollActiveTabIntoViewValue) {
571      this.scrollToActiveTab();
572    }
573
574    // Load content if lazy loading is enabled (after making panel visible)
575    if (this.lazyLoadValue && !this.loadedPanels.has(this.indexValue)) {
576      // Small delay to ensure panel is visible before loading
577      requestAnimationFrame(() => {
578        this.#loadPanelContent(this.indexValue);
579      });
580    }
581  }
582
583  setInitialFocus() {
584    if (this.tabsCount === 0) return;
585
586    const normalizedIndex = this.normalizedEnabledIndex(this.indexValue);
587    if (normalizedIndex === -1) {
588      this.tabTargets.forEach((tab) => {
589        tab.setAttribute("tabindex", "-1");
590        tab.setAttribute("aria-selected", "false");
591      });
592      this.focusedTabIndex = -1;
593      return;
594    }
595
596    if (normalizedIndex !== this.indexValue) {
597      this.indexValue = normalizedIndex;
598      return;
599    }
600
601    // Set initial tabindex values
602    this.tabTargets.forEach((tab, index) => {
603      const disabled = this.isTabDisabled(tab);
604      if (!disabled && index === this.indexValue) {
605        tab.setAttribute("tabindex", "0");
606        tab.setAttribute("aria-selected", "true");
607      } else {
608        tab.setAttribute("tabindex", "-1");
609        tab.setAttribute("aria-selected", "false");
610      }
611    });
612
613    // Ensure focusedTabIndex is in sync with the active tab
614    this.focusedTabIndex = this.indexValue;
615  }
616
617  setFocusToActiveTab() {
618    const activeTab = this.tabTargets[this.indexValue];
619    if (activeTab && !this.isTabDisabled(activeTab)) {
620      // Find the focusable element within the tab (likely the <a> tag)
621      const focusableElement = activeTab.querySelector("a") || activeTab;
622      focusableElement.focus();
623    }
624  }
625
626  setFocusToTab(tabIndex) {
627    const tab = this.tabTargets[tabIndex];
628    if (tab && !this.isTabDisabled(tab)) {
629      // Find the focusable element within the tab (likely the <a> tag)
630      const focusableElement = tab.querySelector("a") || tab;
631      focusableElement.focus();
632    }
633  }
634
635  scrollToActiveTab() {
636    const activeTab = this.tabTargets[this.indexValue];
637    if (activeTab) {
638      activeTab.scrollIntoView({ inline: "center", behavior: "smooth" });
639    }
640  }
641
642  revealTabs() {
643    // Show the tabs after styling has been applied
644    if (this.hasTabListTarget) {
645      this.tabListTarget.classList.remove("opacity-0");
646    }
647  }
648
649  get tabsCount() {
650    return this.tabTargets.length;
651  }
652
653  isTabDisabled(tab) {
654    if (!tab) return true;
655    return tab.disabled || tab.getAttribute("aria-disabled") === "true";
656  }
657
658  isIndexDisabled(index) {
659    return this.isTabDisabled(this.tabTargets[index]);
660  }
661
662  firstEnabledTabIndex() {
663    return this.tabTargets.findIndex((tab) => !this.isTabDisabled(tab));
664  }
665
666  lastEnabledTabIndex() {
667    for (let i = this.tabsCount - 1; i >= 0; i -= 1) {
668      if (!this.isIndexDisabled(i)) return i;
669    }
670    return -1;
671  }
672
673  nextEnabledTabIndex(fromIndex) {
674    if (this.tabsCount === 0) return -1;
675
676    for (let step = 1; step <= this.tabsCount; step += 1) {
677      const index = (fromIndex + step + this.tabsCount) % this.tabsCount;
678      if (!this.isIndexDisabled(index)) return index;
679    }
680    return -1;
681  }
682
683  previousEnabledTabIndex(fromIndex) {
684    if (this.tabsCount === 0) return -1;
685
686    for (let step = 1; step <= this.tabsCount; step += 1) {
687      const index = (fromIndex - step + this.tabsCount) % this.tabsCount;
688      if (!this.isIndexDisabled(index)) return index;
689    }
690    return -1;
691  }
692
693  normalizedEnabledIndex(index) {
694    if (this.tabsCount === 0) return -1;
695
696    const parsed = Number.isFinite(index) ? index : parseInt(index, 10);
697    const clampedIndex = Number.isFinite(parsed) ? Math.max(0, Math.min(parsed, this.tabsCount - 1)) : 0;
698
699    if (!this.isIndexDisabled(clampedIndex)) return clampedIndex;
700
701    const nextIndex = this.nextEnabledTabIndex(clampedIndex);
702    if (nextIndex !== -1) return nextIndex;
703
704    const previousIndex = this.previousEnabledTabIndex(clampedIndex);
705    if (previousIndex !== -1) return previousIndex;
706
707    return clampedIndex;
708  }
709
710  get anchor() {
711    return document.URL.split("#").length > 1 ? document.URL.split("#")[1] : null;
712  }
713
714  getOrientation() {
715    // Check if tabList target has aria-orientation attribute
716    if (this.hasTabListTarget) {
717      const orientation = this.tabListTarget.getAttribute("aria-orientation");
718      if (orientation === "vertical") {
719        return "vertical";
720      } else if (orientation === "horizontal") {
721        return "horizontal";
722      }
723    }
724
725    // Default behavior when no aria-orientation is present: allow both directions
726    // This matches your requirement: "if no aria-orientation is present, let's allow both"
727    return "both";
728  }
729
730  #loadPanelContent(panelIndex) {
731    const panel = this.panelTargets[panelIndex];
732    if (!panel) return;
733
734    // Mark as loaded to prevent multiple load attempts
735    this.loadedPanels.add(panelIndex);
736
737    // Check if we should use Turbo Frame lazy loading
738    if (this.turboFrameSrcValue) {
739      const turboFrame = panel.querySelector("turbo-frame");
740
741      if (turboFrame) {
742        // Only set src if it hasn't been set yet
743        if (!turboFrame.src || turboFrame.src === "" || turboFrame.src === "about:blank") {
744          // Set the src with the panel index to load specific content
745          const baseUrl = this.turboFrameSrcValue;
746          const separator = baseUrl.includes("?") ? "&" : "?";
747          turboFrame.src = `${baseUrl}${separator}tab=${panelIndex}`;
748          // Turbo will handle the loading asynchronously
749        }
750      }
751    } else if (this.hasTemplateTarget) {
752      // Use template-based lazy loading (this is synchronous and fast)
753      const templates = this.templateTargets;
754      const template = templates[panelIndex];
755
756      if (template) {
757        const templateContent = template.content.cloneNode(true);
758
759        // Clear any loading indicators or placeholder content
760        panel.innerHTML = "";
761
762        // Append the template content
763        panel.appendChild(templateContent);
764      }
765    }
766  }
767};

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.