1import { Controller } from "@hotwired/stimulus" 2import { enter, leave } from "lib/transition" 3 4export default class extends Controller { 5 static targets = ["item", "itemHeader", "itemContent", "contentInner", "expandIcon", "image", "imageContainer", "itemsContainer"] 6 static values = { 7 activeItem: String, 8 layoutReversed: Boolean 9 } 10 11 // Animation timing constants 12 static ANIMATION_DURATION = 500 13 static IMAGE_TRANSITION_DURATION = 700 14 static CONTENT_DELAY = 50 15 static COLLAPSE_DELAY = 100 16 17 connect() { 18 this.showActiveItem() 19 } 20 21 disconnect() { 22 // Clean up any ongoing animations 23 this.cancelAllAnimations() 24 } 25 26 showActiveItem() { 27 if (!this.activeItemValue) return 28 29 const activeItem = this.itemTargets.find(item => 30 item.dataset.itemId === this.activeItemValue 31 ) 32 33 if (activeItem) { 34 this.initializeAllItems() 35 } 36 } 37 38 initializeAllItems() { 39 // Set up initial states for all items 40 this.itemTargets.forEach((item, index) => { 41 const isActive = item.dataset.itemId === this.activeItemValue 42 this.initializeItemState(item, index, isActive) 43 }) 44 45 // Set up initial image states 46 this.initializeImageStates() 47 } 48 49 initializeItemState(item, index, isActive) { 50 const header = this.itemHeaderTargets[index] 51 const content = this.itemContentTargets[index] 52 const contentInner = this.contentInnerTargets[index] 53 54 // Update header styling 55 this.updateHeaderStyling(header, isActive) 56 57 // Update content state 58 if (content && contentInner) { 59 this.updateContentState(content, contentInner, isActive) 60 } 61 } 62 63 updateHeaderStyling(header, isActive) { 64 if (isActive) { 65 header.classList.remove('bg-antler-white/30', 'dark:bg-black', 'text-black', 'dark:text-orange-400') 66 header.classList.add('bg-antler-white', 'dark:bg-black', 'inset-shadow', 'text-black', 'dark:text-orange-400') 67 } else { 68 header.classList.remove('bg-antler-white', 'dark:bg-black', 'inset-shadow', 'text-black', 'dark:text-orange-400') 69 header.classList.add('bg-antler-white/30', 'dark:bg-black', 'text-gray-500', 'dark:text-gray-200') 70 } 71 } 72 73 updateContentState(content, contentInner, isActive) { 74 if (isActive) { 75 content.style.maxHeight = 'none' 76 content.style.opacity = '1' 77 contentInner.style.transform = 'translateY(0)' 78 } else { 79 content.style.maxHeight = '0px' 80 content.style.opacity = '0' 81 contentInner.style.transform = 'translateY(-10px)' 82 } 83 } 84 85 initializeImageStates() { 86 this.imageTargets.forEach(image => { 87 const isActive = image.dataset.itemId === this.activeItemValue 88 89 if (isActive) { 90 image.style.opacity = '1' 91 image.style.transform = 'scale(1)' 92 image.style.pointerEvents = 'auto' 93 } else { 94 image.style.opacity = '0' 95 image.style.transform = 'scale(0.95)' 96 image.style.pointerEvents = 'none' 97 } 98 }) 99 } 100 101 toggleItem(event) { 102 const clickedItem = event.currentTarget.closest('[data-interactive-accordion-target="item"]') 103 const clickedItemId = clickedItem.dataset.itemId 104 105 // Prevent toggling the same item 106 if (this.activeItemValue === clickedItemId) { 107 return 108 } 109 110 this.deactivateCurrentItem() 111 this.activateItem(clickedItem) 112 } 113 114 activateItem(item, withTransitions = true) { 115 const itemId = item.dataset.itemId 116 this.activeItemValue = itemId 117 118 this.updateItemStyling(item, true) 119 this.showImage(itemId, withTransitions) 120 121 if (withTransitions) { 122 this.expandContent(item) 123 } 124 } 125 126 deactivateCurrentItem() { 127 const currentActiveItem = this.itemTargets.find(item => 128 item.dataset.itemId === this.activeItemValue 129 ) 130 131 if (currentActiveItem) { 132 this.updateItemStyling(currentActiveItem, false) 133 this.collapseContent(currentActiveItem) 134 } 135 } 136 137 updateItemStyling(item, isActive) { 138 const index = this.itemTargets.indexOf(item) 139 const header = this.itemHeaderTargets[index] 140 const expandIcon = this.expandIconTargets[index] 141 142 this.updateHeaderStyling(header, isActive) 143 this.updateIconRotation(expandIcon, isActive) 144 145 if (isActive) { 146 item.classList.add('bg-antler-white', 'inset-shadow', 'text-black') 147 item.classList.remove('bg-antler-white/30', 'text-gray-500') 148 } else { 149 item.classList.remove('bg-antler-white', 'inset-shadow', 'text-black') 150 item.classList.add('bg-antler-white/30', 'text-gray-500') 151 } 152 153 } 154 155 updateIconRotation(expandIcon, isActive) { 156 expandIcon.style.transform = isActive ? 'rotate(0deg)' : 'rotate(180deg)' 157 } 158
159 expandContent(item) { 160 const index = this.itemTargets.indexOf(item) 161 const content = this.itemContentTargets[index] 162 const contentInner = this.contentInnerTargets[index] 163 164 if (!content) return 165 166 // Set initial state 167 content.style.maxHeight = '0px' 168 content.style.opacity = '1' 169 content.offsetHeight // Force reflow 170 171 // Animate to full height 172 const targetHeight = content.scrollHeight 173 content.style.maxHeight = `${targetHeight}px` 174 175 // Animate inner content 176 if (contentInner) { 177 setTimeout(() => { 178 contentInner.style.transform = 'translateY(0)' 179 }, this.constructor.CONTENT_DELAY) 180 } 181 182 // Clean up after animation 183 setTimeout(() => { 184 content.style.maxHeight = 'none' 185 }, this.constructor.ANIMATION_DURATION) 186 } 187 188 collapseContent(item) { 189 const index = this.itemTargets.indexOf(item) 190 const content = this.itemContentTargets[index] 191 const contentInner = this.contentInnerTargets[index] 192 193 if (!content) return 194 195 const currentHeight = content.scrollHeight 196 content.style.maxHeight = `${currentHeight}px` 197 content.offsetHeight // Force reflow 198 199 // Animate inner content first 200 if (contentInner) { 201 contentInner.style.transform = 'translateY(-10px)' 202 } 203 204 // Then collapse container 205 setTimeout(() => { 206 content.style.maxHeight = '0px' 207 content.style.opacity = '0' 208 }, this.constructor.COLLAPSE_DELAY) 209 } 210 211 showImage(itemId, withTransitions = true) { 212 const transitionStyle = withTransitions 213 ? `opacity ${this.constructor.IMAGE_TRANSITION_DURATION}ms ease-in-out, transform ${this.constructor.IMAGE_TRANSITION_DURATION}ms ease-in-out` 214 : 'none' 215 216 // Hide all images 217 this.imageTargets.forEach(image => { 218 image.style.transition = transitionStyle 219 image.style.opacity = '0' 220 image.style.transform = 'scale(0.95)' 221 image.style.pointerEvents = 'none' 222 }) 223 224 // Show target image 225 const targetImage = this.imageTargets.find(image => 226 image.dataset.itemId === itemId 227 ) 228 229 if (targetImage) { 230 targetImage.style.opacity = '1' 231 targetImage.style.transform = 'scale(1)' 232 targetImage.style.pointerEvents = 'auto' 233 } 234 } 235 236 // Public method to programmatically activate an item 237 activateItemById(itemId) { 238 const item = this.itemTargets.find(item => 239 item.dataset.itemId === itemId 240 ) 241 242 if (item) { 243 this.activateItem(item) 244 } 245 } 246 247 // Cleanup method 248 cancelAllAnimations() { 249 // Cancel any ongoing timeouts or animations 250 // This is a placeholder for future cleanup needs 251 } 252}
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.