1import { Controller } from "@hotwired/stimulus"; 2 3export default class extends Controller { 4 static targets = ["item", "trigger", "content", "icon"]; 5 static values = { 6 allowMultiple: { type: Boolean, default: false }, // Allow multiple items to be open at once 7 }; 8 9 // Get only the direct child targets (not nested accordion targets) 10 get ownItemTargets() { 11 return this.itemTargets.filter((item) => item.closest('[data-controller="accordion"]') === this.element); 12 } 13 14 get ownTriggerTargets() { 15 return this.triggerTargets.filter((trigger) => trigger.closest('[data-controller="accordion"]') === this.element); 16 } 17 18 get ownContentTargets() { 19 return this.contentTargets.filter((content) => content.closest('[data-controller="accordion"]') === this.element); 20 } 21 22 get ownIconTargets() { 23 return this.iconTargets.filter((icon) => icon.closest('[data-controller="accordion"]') === this.element); 24 } 25 26 _applyOpenVisuals(index) { 27 const items = this.ownItemTargets; 28 const triggers = this.ownTriggerTargets; 29 const contents = this.ownContentTargets; 30 const icons = this.ownIconTargets; 31 32 if (!items[index] || !triggers[index] || !contents[index]) return; 33 34 const item = items[index]; 35 const trigger = triggers[index]; 36 const content = contents[index]; 37 const icon = icons[index] || null; 38 39 // Remove hidden first to allow measurement 40 content.removeAttribute("hidden"); 41 // Force reflow to ensure hidden is removed before animation starts 42 content.offsetHeight; 43 44 item.dataset.state = "open"; 45 trigger.setAttribute("aria-expanded", "true"); 46 trigger.dataset.state = "open"; 47 content.dataset.state = "open"; 48 49 // Update inner wrapper and body states for opacity transition 50 this._updateInnerStates(content, "open"); 51 52 if (icon) { 53 const isPlusMinus = icon.querySelector('path[d*="M5 12h14"]'); 54 const isLeftChevron = icon.classList.contains("-rotate-90"); 55 56 if (isPlusMinus) { 57 // For plus/minus icons, CSS handles the animation via aria-expanded 58 } else if (isLeftChevron) { 59 icon.classList.add("rotate-0"); 60 icon.classList.remove("-rotate-90"); 61 } else { 62 icon.classList.add("rotate-180"); 63 } 64 } 65 } 66 67 _applyClosedVisuals(index, skipHidden = false) { 68 const items = this.ownItemTargets; 69 const triggers = this.ownTriggerTargets; 70 const contents = this.ownContentTargets; 71 const icons = this.ownIconTargets; 72 73 if (!items[index] || !triggers[index] || !contents[index]) return; 74 75 const item = items[index]; 76 const trigger = triggers[index]; 77 const content = contents[index]; 78 const icon = icons[index] || null; 79 80 item.dataset.state = "closed"; 81 trigger.setAttribute("aria-expanded", "false"); 82 trigger.dataset.state = "closed"; 83 content.dataset.state = "closed"; 84 85 // Update inner wrapper and body states for opacity transition 86 this._updateInnerStates(content, "closed"); 87 88 // For initial setup, set hidden immediately; for animations, it's handled in close() 89 if (!skipHidden) { 90 content.setAttribute("hidden", ""); 91 } 92 93 if (icon) { 94 const isPlusMinus = icon.classList.contains("scale-0"); 95 const isLeftChevron = icon.classList.contains("rotate-0"); 96 97 if (isPlusMinus) { 98 // For plus/minus icons, CSS handles the animation 99 } else if (isLeftChevron) { 100 icon.classList.remove("rotate-0"); 101 icon.classList.add("-rotate-90"); 102 } else { 103 icon.classList.remove("rotate-180"); 104 } 105 } 106 } 107 108 connect() { 109 this.addKeyboardListeners(); 110 this.activeIndices = new Set(); 111 112 // Store bound function references for proper cleanup 113 this.boundHandleTriggerKeydown = this.handleTriggerKeydown.bind(this); 114 this.boundHandleKeydown = this.handleKeydown.bind(this); 115 116 const ownTriggers = this.ownTriggerTargets; 117 const ownItems = this.ownItemTargets; 118 119 // Ensure all trigger buttons are focusable and have keyboard listeners 120 ownTriggers.forEach((trigger) => { 121 if (!trigger.hasAttribute("tabindex")) { 122 trigger.setAttribute("tabindex", "0"); 123 } 124 trigger.addEventListener("keydown", this.boundHandleTriggerKeydown); 125 }); 126 127 const initiallyOpenIndexesFromDOM = [];
128 ownItems.forEach((item, index) => { 129 if (ownTriggers[index]?.getAttribute("aria-expanded") === "true") { 130 initiallyOpenIndexesFromDOM.push(index); 131 } else if (item.dataset.state === "open" && !initiallyOpenIndexesFromDOM.includes(index)) { 132 initiallyOpenIndexesFromDOM.push(index); 133 } 134 }); 135 136 if (!this.allowMultipleValue) { 137 if (initiallyOpenIndexesFromDOM.length > 0) { 138 const indexToKeepOpen = initiallyOpenIndexesFromDOM[0]; 139 this.activeIndices.add(indexToKeepOpen); 140 this._applyOpenVisuals(indexToKeepOpen); 141 142 for (let i = 0; i < ownItems.length; i++) { 143 if (i !== indexToKeepOpen) { 144 this._applyClosedVisuals(i); 145 } 146 } 147 } else { 148 ownItems.forEach((_, index) => this._applyClosedVisuals(index)); 149 } 150 } else { 151 initiallyOpenIndexesFromDOM.forEach((index) => { 152 this.activeIndices.add(index); 153 this._applyOpenVisuals(index); 154 }); 155 ownItems.forEach((_, index) => { 156 if (!this.activeIndices.has(index)) { 157 this._applyClosedVisuals(index); 158 } 159 }); 160 } 161 } 162 163 disconnect() { 164 this.element.removeEventListener("keydown", this.boundHandleKeydown); 165 166 // Remove individual trigger listeners 167 this.ownTriggerTargets.forEach((trigger) => { 168 trigger.removeEventListener("keydown", this.boundHandleTriggerKeydown); 169 }); 170 171 // Clean up transition listeners 172 this.ownContentTargets.forEach((content) => { 173 if (content._onTransitionEnd) { 174 content.removeEventListener("transitionend", content._onTransitionEnd); 175 } 176 }); 177 } 178 179 addKeyboardListeners() { 180 this.element.addEventListener("keydown", this.boundHandleKeydown); 181 } 182 183 // Safari-compatible trigger-specific keydown handler 184 handleTriggerKeydown(event) { 185 const currentTrigger = event.currentTarget; 186 const ownTriggers = this.ownTriggerTargets; 187 const currentIndex = ownTriggers.indexOf(currentTrigger); 188 189 if (currentIndex === -1) return; 190 191 switch (event.key) { 192 case "ArrowUp": 193 event.preventDefault(); 194 this.focusPreviousItem(currentIndex); 195 break; 196 case "ArrowDown": 197 event.preventDefault(); 198 this.focusNextItem(currentIndex); 199 break; 200 case "Home": 201 event.preventDefault(); 202 this.focusFirstItem(); 203 break; 204 case "End": 205 event.preventDefault(); 206 this.focusLastItem(); 207 break; 208 case "Enter": 209 case " ": // Space key 210 event.preventDefault(); 211 this.toggle(event); 212 break; 213 } 214 } 215 216 handleKeydown(event) { 217 const ownTriggers = this.ownTriggerTargets; 218 let currentIndex = -1; 219 220 ownTriggers.forEach((trigger, index) => { 221 if (trigger === document.activeElement || trigger.contains(document.activeElement)) { 222 currentIndex = index; 223 } 224 }); 225 226 if (currentIndex === -1) return; 227 228 switch (event.key) { 229 case "ArrowUp": 230 event.preventDefault(); 231 this.focusPreviousItem(currentIndex); 232 break; 233 case "ArrowDown": 234 event.preventDefault(); 235 this.focusNextItem(currentIndex); 236 break; 237 case "Home": 238 event.preventDefault(); 239 this.focusFirstItem(); 240 break; 241 case "End": 242 event.preventDefault(); 243 this.focusLastItem(); 244 break; 245 } 246 } 247 248 focusPreviousItem(currentIndex) { 249 const ownTriggers = this.ownTriggerTargets; 250 const previousIndex = (currentIndex - 1 + ownTriggers.length) % ownTriggers.length; 251 ownTriggers[previousIndex].focus(); 252 } 253 254 focusNextItem(currentIndex) { 255 const ownTriggers = this.ownTriggerTargets; 256 const nextIndex = (currentIndex + 1) % ownTriggers.length; 257 ownTriggers[nextIndex].focus(); 258 } 259 260 focusFirstItem() { 261 this.ownTriggerTargets[0]?.focus(); 262 } 263 264 focusLastItem() { 265 const ownTriggers = this.ownTriggerTargets; 266 ownTriggers[ownTriggers.length - 1]?.focus(); 267 } 268 269 toggle(event) { 270 const ownTriggers = this.ownTriggerTargets; 271 const index = ownTriggers.indexOf(event.currentTarget); 272 273 if (index === -1) return; 274 275 if (this.activeIndices.has(index)) { 276 this.close(index); 277 } else { 278 if (!this.allowMultipleValue) {
279 this.activeIndices.forEach((i) => this.close(i)); 280 } 281 this.open(index); 282 } 283 } 284 285 open(index) { 286 const items = this.ownItemTargets; 287 const triggers = this.ownTriggerTargets; 288 const contents = this.ownContentTargets; 289 const icons = this.ownIconTargets; 290 291 const item = items[index]; 292 const trigger = triggers[index]; 293 const content = contents[index]; 294 const icon = icons[index]; 295 296 if (!item || !trigger || !content) return; 297 298 // Remove hidden first to allow CSS Grid animation 299 content.removeAttribute("hidden"); 300 // Force reflow to ensure hidden is removed before animation starts 301 content.offsetHeight; 302 303 // Set the open state - CSS Grid handles the animation 304 item.dataset.state = "open"; 305 trigger.setAttribute("aria-expanded", "true"); 306 trigger.dataset.state = "open"; 307 content.dataset.state = "open"; 308 309 // Update inner wrapper and body states for opacity transition 310 this._updateInnerStates(content, "open"); 311 312 // Handle icon animation 313 if (icon) { 314 const isPlusMinus = icon.querySelector('path[d*="M5 12h14"]'); 315 const isLeftChevron = icon.classList.contains("-rotate-90"); 316 317 if (isPlusMinus) { 318 // CSS handles plus/minus via aria-expanded 319 } else if (isLeftChevron) { 320 icon.classList.add("rotate-0"); 321 icon.classList.remove("-rotate-90"); 322 } else { 323 icon.classList.add("rotate-180"); 324 } 325 } 326 327 this.activeIndices.add(index); 328 } 329 330 close(index) { 331 const items = this.ownItemTargets; 332 const triggers = this.ownTriggerTargets; 333 const contents = this.ownContentTargets; 334 const icons = this.ownIconTargets; 335 336 const item = items[index]; 337 const trigger = triggers[index]; 338 const content = contents[index]; 339 const icon = icons[index]; 340 341 if (!item || !trigger || !content) return; 342 343 // Set closed state - CSS Grid handles the animation 344 item.dataset.state = "closed"; 345 trigger.setAttribute("aria-expanded", "false"); 346 trigger.dataset.state = "closed"; 347 content.dataset.state = "closed"; 348 349 // Update inner wrapper and body states for opacity transition 350 this._updateInnerStates(content, "closed"); 351 352 // Handle icon animation 353 if (icon) { 354 const isPlusMinus = icon.querySelector('path[d*="M5 12h14"]'); 355 const isLeftChevron = icon.classList.contains("rotate-0"); 356 357 if (isPlusMinus) { 358 // CSS handles plus/minus via aria-expanded 359 } else if (isLeftChevron) { 360 icon.classList.remove("rotate-0"); 361 icon.classList.add("-rotate-90"); 362 } else { 363 icon.classList.remove("rotate-180"); 364 } 365 } 366 367 // Remove any existing listener 368 if (content._onTransitionEnd) { 369 content.removeEventListener("transitionend", content._onTransitionEnd); 370 } 371 372 // Add hidden after transition completes for accessibility 373 content._onTransitionEnd = (e) => { 374 if (e.propertyName === "grid-template-rows" && content.dataset.state === "closed") { 375 content.setAttribute("hidden", ""); 376 } 377 content.removeEventListener("transitionend", content._onTransitionEnd); 378 }; 379 content.addEventListener("transitionend", content._onTransitionEnd); 380 381 this.activeIndices.delete(index); 382 } 383 384 // Update data-state on inner wrapper elements (for opacity transitions) 385 _updateInnerStates(content, state) { 386 // Find direct children with data-state attribute and update them 387 const innerElements = content.querySelectorAll(":scope > [data-state], :scope > * > [data-state]"); 388 innerElements.forEach((el) => { 389 // Only update if it's not a nested accordion element 390 if ( 391 !el.closest('[data-controller="accordion"]') || 392 el.closest('[data-controller="accordion"]') === this.element 393 ) { 394 el.dataset.state = state; 395 } 396 }); 397 } 398};
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.