1import { Controller } from "@hotwired/stimulus" 2 3// Company Settings: one panel visible; active section is reflected in the URL as #fragment. 4// Optional ?tab=panel_id is accepted for deep links and normalized to #panel_id (query param removed). 5// Tab bar uses flex-wrap so pills flow to multiple rows instead of horizontal scroll. 6export default class extends Controller { 7 static targets = ["tab", "panel"] 8 static values = { 9 defaultPanel: String, 10 } 11 12 connect() { 13 this._onHashChange = this._onHashChange.bind(this) 14 this._onPopState = this._onPopState.bind(this) 15 window.addEventListener("hashchange", this._onHashChange) 16 window.addEventListener("popstate", this._onPopState) 17 18 const fromQuery = this._panelIdFromQuery() 19 const fromHash = this._panelIdFromHash() 20 21 let panelId = null 22 if (fromQuery && this._validTab(fromQuery)) { 23 panelId = fromQuery 24 this._replaceUrlHash(panelId) 25 } else if (fromHash && this._validTab(fromHash)) { 26 panelId = fromHash 27 } else if (fromHash || fromQuery) { 28 panelId = this._fallbackPanelId() 29 this._replaceUrlHash(panelId) 30 } else { 31 panelId = this._fallbackPanelId() 32 } 33 34 this.activate(panelId, { syncUrl: false }) 35 } 36 37 disconnect() { 38 window.removeEventListener("hashchange", this._onHashChange) 39 window.removeEventListener("popstate", this._onPopState) 40 } 41 42 show(event) { 43 const panelId = event.currentTarget.dataset.panel 44 if (!panelId) return 45 this.activate(panelId, { syncUrl: true }) 46 } 47 48 /** @param {{ syncUrl?: boolean }} opts */ 49 activate(panelId, opts = {}) { 50 const { syncUrl = true } = opts 51 if (!this._validTab(panelId)) return 52 53 this.panelTargets.forEach((panel) => { 54 panel.classList.toggle("hidden", panel.dataset.panel !== panelId) 55 }) 56 57 this.tabTargets.forEach((tab) => { 58 const selected = tab.dataset.panel === panelId 59 tab.setAttribute("aria-selected", String(selected)) 60 tab.classList.toggle("border-sc-crimson", selected) 61 tab.classList.toggle("bg-white", selected) 62 tab.classList.toggle("text-sc-crimson", selected) 63 tab.classList.toggle("shadow-sm", selected) 64 tab.classList.toggle("border-transparent", !selected) 65 tab.classList.toggle("text-sc-text-2", !selected) 66 }) 67 68 if (syncUrl) this._replaceUrlHash(panelId) 69 } 70 71 _onHashChange() { 72 const id = this._panelIdFromHash() 73 if (this._validTab(id)) this.activate(id, { syncUrl: false }) 74 } 75 76 _onPopState() { 77 const id = this._panelIdFromHash() 78 if (this._validTab(id)) this.activate(id, { syncUrl: false }) 79 else if (id) { 80 const fallback = this._fallbackPanelId() 81 this.activate(fallback, { syncUrl: true }) 82 } else { 83 this.activate(this._fallbackPanelId(), { syncUrl: false }) 84 } 85 } 86 87 _validTab(panelId) { 88 if (!panelId) return false 89 return this.tabTargets.some((t) => t.dataset.panel === panelId) 90 } 91 92 _fallbackPanelId() { 93 if (this.defaultPanelValue && this._validTab(this.defaultPanelValue)) { 94 return this.defaultPanelValue 95 } 96 return this.tabTargets[0]?.dataset.panel || "" 97 } 98 99 _panelIdFromHash() { 100 const raw = (window.location.hash || "").replace(/^#/, "") 101 if (!raw) return "" 102 try { 103 return decodeURIComponent(raw) 104 } catch { 105 return raw 106 } 107 } 108 109 _panelIdFromQuery() { 110 try { 111 return new URL(window.location.href).searchParams.get("tab") || "" 112 } catch { 113 return "" 114 } 115 } 116 117 /** Sets #fragment, drops ?tab=, preserves other query params */ 118 _replaceUrlHash(panelId) { 119 try { 120 const url = new URL(window.location.href) 121 url.searchParams.delete("tab") 122 url.hash = panelId 123 const next = url.toString() 124 if (window.location.href === next) return 125 history.replaceState(null, document.title, next) 126 } catch { 127 /* ignore */ 128 } 129 } 130} 131;
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.