1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static targets = ["tab", "panel"] 5 static classes = ["active", "inactive"] 6 static values = { current: String } 7 8 connect() { 9 this.boundSubmit = this.onSubmit.bind(this) 10 this.boundPopstate = this.onPopstate.bind(this) 11 // Capture submit events so we can preserve tab context on POST/PATCH/DELETE forms. 12 this.element.addEventListener("submit", this.boundSubmit, true) 13 window.addEventListener("popstate", this.boundPopstate) 14 15 const urlTab = new URL(window.location.href).searchParams.get("tab") 16 if (this.isKnownTab(urlTab)) this.currentValue = urlTab 17 18 if (!this.isKnownTab(this.currentValue) && this.tabTargets.length > 0) { 19 this.currentValue = this.tabTargets[0].dataset.tab 20 } 21 22 this.showCurrent() 23 this.syncTabState() 24 } 25 26 disconnect() { 27 if (this.boundSubmit) this.element.removeEventListener("submit", this.boundSubmit, true) 28 if (this.boundPopstate) window.removeEventListener("popstate", this.boundPopstate) 29 } 30 31 select(event) { 32 this.currentValue = event.currentTarget.dataset.tab 33 this.showCurrent() 34 this.syncTabState({ push: true }) 35 } 36 37 navigate(event) { 38 const keys = ["ArrowLeft", "ArrowRight", "Home", "End"] 39 if (!keys.includes(event.key)) return 40 41 const tabs = this.tabTargets.filter((tab) => tab.getAttribute("role") === "tab" && !tab.disabled) 42 const currentIndex = tabs.indexOf(event.currentTarget) 43 if (currentIndex < 0 || tabs.length < 2) return 44 45 event.preventDefault() 46 let nextIndex 47 if (event.key === "Home") nextIndex = 0 48 if (event.key === "End") nextIndex = tabs.length - 1 49 if (event.key === "ArrowLeft") nextIndex = (currentIndex - 1 + tabs.length) % tabs.length 50 if (event.key === "ArrowRight") nextIndex = (currentIndex + 1) % tabs.length 51 52 const nextTab = tabs[nextIndex] 53 this.currentValue = nextTab.dataset.tab 54 this.showCurrent() 55 this.syncTabState({ push: true }) 56 nextTab.focus() 57 } 58 59 showCurrent() { 60 this.tabTargets.forEach((tab) => { 61 const isActive = tab.dataset.tab === this.currentValue 62 if (this.hasActiveClass) this.toggleClasses(tab, this.activeClasses, isActive) 63 if (this.hasInactiveClass) this.toggleClasses(tab, this.inactiveClasses, !isActive) 64 tab.setAttribute("aria-selected", isActive ? "true" : "false") 65 if (tab.getAttribute("role") === "tab") tab.tabIndex = isActive ? 0 : -1 66 }) 67 68 this.panelTargets.forEach((panel) => { 69 const isActive = panel.dataset.tab === this.currentValue 70 panel.classList.toggle("hidden", !isActive) 71 if (panel.getAttribute("role") === "tabpanel") panel.hidden = !isActive 72 }) 73 } 74 75 syncTabState({ push = false } = {}) { 76 this.syncUrl({ push }) 77 this.syncReturnTabInputs() 78 } 79 80 syncUrl({ push = false } = {}) { 81 const url = new URL(window.location.href) 82 url.searchParams.set("tab", this.currentValue) 83 const state = this.buildHistoryState() 84 if (push) { 85 window.history.pushState(state, "", url) 86 } else { 87 window.history.replaceState(state, "", url) 88 } 89 } 90 91 buildHistoryState() { 92 // Turbo Drive stores navigation state in history.state.turbo. 93 // If we overwrite history.state, Turbo back/forward breaks. 94 const base = window.history.state || {} 95 const chamberhive = base.chamberhive || {} 96 return { ...base, chamberhive: { ...chamberhive, tab: this.currentValue } } 97 } 98 99 syncReturnTabInputs() { 100 // Some forms (e.g. edit dialogs) are outside the tabs element, but still 101 // need to round-trip the current tab on submit. We sync all `return_tab` 102 // hidden inputs on the page. 103 document.querySelectorAll("input[name='return_tab']").forEach((input) => { 104 input.value = this.currentValue 105 }) 106 } 107 108 onSubmit(event) { 109 const form = event.target 110 if (!(form instanceof HTMLFormElement)) return 111 if (!this.currentValue) return 112 113 // Ensure tab param round-trips to the server so redirects/render stay on the same tab. 114 const url = new URL(form.action, window.location.origin) 115 url.searchParams.set("tab", this.currentValue) 116 form.action = `${url.pathname}${url.search}` 117 } 118 119 onPopstate() { 120 // When the user navigates back/forward, update the visible tab to match 121 // the URL. Do not write back to history from here. 122 const urlTab = new URL(window.location.href).searchParams.get("tab") 123 const fallback = this.tabTargets[0] ? this.tabTargets[0].dataset.tab : null 124 const desired = this.isKnownTab(urlTab) ? urlTab : fallback 125 if (!desired) return 126 if (desired === this.currentValue) return 127 128 this.currentValue = desired 129 this.showCurrent() 130 this.syncReturnTabInputs() 131 } 132 133 toggleClasses(element, classNames, enabled) { 134 if (!classNames) return
135 classNames.forEach((className) => { 136 if (!className || className.trim().length === 0) return 137 element.classList.toggle(className, enabled) 138 }) 139 } 140 141 isKnownTab(tabKey) { 142 if (!tabKey) return false 143 return this.tabTargets.some((tab) => tab.dataset.tab === tabKey) 144 } 145}
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.