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.