1import { Controller } from "@hotwired/stimulus"; 2import gsap from "gsap"; 3 4export default class extends Controller { 5 static targets = ["container", "logo", "content", "nav"]; 6 static values = { startExpanded: { type: Boolean, default: false } }; 7 8 connect() { 9 this.expanded = false; 10 this.animation = null; 11 this.lastMobileState = this.isMobile; 12 13 this.measureDimensions(); 14 15 if (this.startExpandedValue) { 16 this.expandImmediate(); 17 } 18 19 // Re-measure on resize only when crossing mobile/desktop threshold 20 this.boundResize = this.onResize.bind(this); 21 window.addEventListener("resize", this.boundResize); 22 23 // Collapse on scroll (mobile only) 24 this.boundScroll = this.onScroll.bind(this); 25 window.addEventListener("scroll", this.boundScroll, { passive: true }); 26 } 27 28 disconnect() { 29 window.removeEventListener("resize", this.boundResize); 30 window.removeEventListener("scroll", this.boundScroll); 31 } 32 33 onScroll() { 34 if (this.isMobile && this.expanded && !this.animating) { 35 this.collapse(); 36 } 37 } 38 39 get isMobile() { 40 return window.innerWidth < 1024; // lg breakpoint â includes tablets 41 } 42
43 // Two questions, not one. isMobile is how wide the window is, which decides 44 // the layout; this is whether the pointer can hover at all, which decides 45 // whether hovering should mean anything. 46 // 47 // A touch screen synthesises mouseenter immediately before click. On an iPad 48 // held in landscape the window is wide enough to count as desktop, so the 49 // synthesised hover opened the island and the very tap that caused it found 50 // it open and closed it again. In portrait the same pair fired, and the 51 // animating guard in toggle happened to swallow the click, which is why it 52 // only looked broken one way up. 53 get hoverable() { 54 return window.matchMedia("(hover: hover)").matches; 55 } 56 57 // Whether the pill has to be opened by tapping it, because there is no hover 58 // to open it with. 59 // 60 // One predicate, read by both the hover handlers and the logo, because they 61 // are two halves of the same decision: wherever hover cannot open the pill, 62 // the tap has to, which means the logo cannot be a link to the home page. 63 // Guarding only one of them left the hover correctly ignored and the tap 64 // still navigating away. 65 get tapToOpen() { 66 return this.isMobile || !this.hoverable; 67 } 68 69 // Bound to mouseenter and mouseleave in place of expand and collapse, so a 70 // pointer that cannot hover cannot reach them, while a tap still can. 71 hoverExpand() { 72 if (this.tapToOpen) return; 73 74 this.expand(); 75 } 76 77 hoverCollapse() { 78 if (this.tapToOpen) return; 79 80 this.collapse(); 81 } 82 83 onResize() { 84 // Only re-measure when crossing mobile/desktop threshold 85 if (this.isMobile !== this.lastMobileState) { 86 this.lastMobileState = this.isMobile; 87 // Collapse first, then re-measure 88 if (this.expanded) { 89 this.expanded = false; 90 } 91 if (this.animation) this.animation.kill(); 92 gsap.set(this.containerTarget, { clearProps: "all" }); 93 gsap.set(this.contentTarget, { clearProps: "all" }); 94 this.measureDimensions(); 95 } 96 } 97 98 measureDimensions() { 99 if (this.isMobile) { 100 this.measureMobile(); 101 } else { 102 this.measureDesktop(); 103 } 104 } 105 106 measureMobile() { 107 // Measure collapsed state (just logo, tightly wrapped) 108 this.contentTarget.style.opacity = "0"; 109 this.contentTarget.style.height = "0"; 110 this.contentTarget.style.width = "0"; 111 this.contentTarget.style.overflow = "hidden"; 112 this.containerTarget.style.width = "auto"; 113 114 this.collapsedHeight = this.containerTarget.getBoundingClientRect().height; 115 this.collapsedWidth = this.containerTarget.getBoundingClientRect().width; 116 117 // Measure expanded state â use most of the viewport width so buttons wrap correctly 118 const maxWidth = Math.min(window.innerWidth - 32, 600); 119 this.contentTarget.style.opacity = "1"; 120 this.contentTarget.style.height = "auto"; 121 this.contentTarget.style.width = "auto"; 122 this.containerTarget.style.width = maxWidth + "px"; 123 124 this.expandedHeight = this.containerTarget.getBoundingClientRect().height; 125 this.expandedWidth = maxWidth; 126 this.contentExpandedHeight = 127 this.contentTarget.getBoundingClientRect().height; 128 this.contentExpandedWidth = 129 this.contentTarget.getBoundingClientRect().width; 130 131 // Reset to collapsed 132 this.containerTarget.style.width = "auto"; 133 this.contentTarget.style.opacity = "0"; 134 this.contentTarget.style.height = "0"; 135 this.contentTarget.style.width = "0"; 136 this.contentTarget.style.overflow = "hidden"; 137 } 138 139 measureDesktop() { 140 const nav = this.hasNavTarget 141 ? this.navTarget 142 : this.containerTarget.querySelector("nav"); 143 144 const originalIslandStyleWidth = this.containerTarget.style.width; 145 const originalIslandStylePaddingLeft = 146 this.containerTarget.style.paddingLeft; 147 const originalIslandStylePaddingRight = 148 this.containerTarget.style.paddingRight; 149 const originalIslandStyleFlexShrink = this.containerTarget.style.flexShrink; 150 const originalNavJustifyContent = nav.style.justifyContent; 151 152 const originalContentStyleOpacity = this.contentTarget.style.opacity; 153 const originalContentStyleTransform = this.contentTarget.style.transform; 154 const originalContentStyleWidth = this.contentTarget.style.width; 155 const originalContentStyleHeight = this.contentTarget.style.height; 156 const originalContentStyleFlexGrow = this.contentTarget.style.flexGrow; 157 const originalContentStyleFlexShrink = this.contentTarget.style.flexShrink; 158 const originalContentStylePosition = this.contentTarget.style.position; 159 160 // Measure expanded width 161 this.containerTarget.style.width = "auto"; 162 this.containerTarget.style.paddingLeft = "24px"; 163 this.containerTarget.style.paddingRight = "24px"; 164 this.containerTarget.style.flexShrink = "0"; 165 nav.style.justifyContent = "space-between"; 166 167 this.contentTarget.style.opacity = "1"; 168 this.contentTarget.style.transform = "translateX(0%)"; 169 this.contentTarget.style.width = "auto"; 170 this.contentTarget.style.height = "auto";
171 this.contentTarget.style.flexGrow = "1"; 172 this.contentTarget.style.flexShrink = "0"; 173 this.contentTarget.style.position = "static"; 174 175 this.expandedWidth = this.containerTarget.getBoundingClientRect().width; 176 this.actualContentExpandedWidth = 177 this.contentTarget.getBoundingClientRect().width; 178 179 // Measure collapsed width 180 this.contentTarget.style.opacity = "0"; 181 this.contentTarget.style.transform = "translateX(100%)"; 182 this.contentTarget.style.width = "0px"; 183 this.contentTarget.style.flexGrow = "0"; 184 this.contentTarget.style.flexShrink = "1"; 185 this.contentTarget.style.position = "absolute"; 186 187 this.containerTarget.style.width = "auto"; 188 this.containerTarget.style.paddingLeft = "16px"; 189 this.containerTarget.style.paddingRight = "16px"; 190 this.containerTarget.style.flexShrink = "1"; 191 nav.style.justifyContent = "center"; 192 193 this.collapsedWidth = this.containerTarget.getBoundingClientRect().width; 194 195 // Restore original styles 196 this.containerTarget.style.width = originalIslandStyleWidth; 197 this.containerTarget.style.paddingLeft = originalIslandStylePaddingLeft; 198 this.containerTarget.style.paddingRight = originalIslandStylePaddingRight; 199 this.containerTarget.style.flexShrink = originalIslandStyleFlexShrink; 200 nav.style.justifyContent = originalNavJustifyContent; 201 202 this.contentTarget.style.opacity = originalContentStyleOpacity; 203 this.contentTarget.style.transform = originalContentStyleTransform; 204 this.contentTarget.style.width = originalContentStyleWidth; 205 this.contentTarget.style.height = originalContentStyleHeight; 206 this.contentTarget.style.flexGrow = originalContentStyleFlexGrow; 207 this.contentTarget.style.flexShrink = originalContentStyleFlexShrink; 208 this.contentTarget.style.position = originalContentStylePosition; 209 210 this.containerTarget.style.transition = ""; 211 this.contentTarget.style.transition = ""; 212 } 213 214 handleLogoClick(event) { 215 // Where the tap is what opens the pill, the logo cannot also be a link: 216 // tapping it opened the nav and navigated to the home page in the same 217 // gesture, so the nav was gone before it could be read. 218 if (this.tapToOpen) { 219 event.preventDefault(); 220 event.stopPropagation(); 221 this.toggle(event); 222 } 223 // Where hover opens the pill, the logo is just a link home. 224 } 225 226 toggle(event) { 227 // Only prevent double toggle on mobile 228 if (this.isMobile && this.animating) return; 229 230 if (this.expanded) { 231 this.collapse(); 232 } else { 233 this.expand(); 234 } 235 } 236 237 expandImmediate() { 238 if (this.isMobile) { 239 gsap.set(this.contentTarget, { 240 width: this.contentExpandedWidth, 241 opacity: 1, 242 height: this.contentExpandedHeight, 243 overflow: "visible", 244 }); 245 gsap.set(this.containerTarget, { width: this.expandedWidth }); 246 } else { 247 const nav = this.hasNavTarget 248 ? this.navTarget 249 : this.containerTarget.querySelector("nav"); 250 gsap.set(this.containerTarget, { 251 width: this.expandedWidth, 252 paddingLeft: 24, 253 paddingRight: 24, 254 }); 255 gsap.set(nav, { justifyContent: "space-between" }); 256 gsap.set(this.contentTarget, { 257 opacity: 1, 258 x: "0%", 259 width: this.actualContentExpandedWidth, 260 }); 261 } 262 this.expanded = true; 263 } 264 265 expand() { 266 // Only prevent double expand on mobile 267 if (this.isMobile && (this.expanded || this.animating)) return; 268 269 if (this.animation) this.animation.kill(); 270 this.animating = true; 271 272 if (this.isMobile) { 273 this.expandMobile(); 274 } else { 275 this.expandDesktop(); 276 } 277 278 this.expanded = true; 279 } 280 281 expandMobile() { 282 this.animation = gsap.timeline({ 283 defaults: { duration: 0.35, ease: "power2.out" }, 284 onComplete: () => { 285 this.animating = false; 286 }, 287 }); 288 289 // Set content width immediately to prevent button reflow during animation 290 gsap.set(this.contentTarget, { width: this.contentExpandedWidth }); 291 292 // Expand container and content height together 293 this.animation.to( 294 this.containerTarget, 295 { 296 width: this.expandedWidth, 297 }, 298 0, 299 ); 300 301 this.animation.to( 302 this.contentTarget, 303 { 304 opacity: 1, 305 height: this.contentExpandedHeight, 306 overflow: "visible", 307 }, 308 0, 309 ); 310 } 311
312 expandDesktop() { 313 const nav = this.hasNavTarget 314 ? this.navTarget 315 : this.containerTarget.querySelector("nav"); 316 317 this.animation = gsap.timeline({ 318 defaults: { duration: 0.5, ease: "power2.out" }, 319 onComplete: () => { 320 this.animating = false; 321 }, 322 }); 323 324 this.animation.to( 325 this.containerTarget, 326 { 327 width: this.expandedWidth, 328 paddingLeft: 24, 329 paddingRight: 24, 330 }, 331 0, 332 ); 333 334 this.animation.set(nav, { justifyContent: "space-between" }, 0); 335 336 this.animation.to( 337 this.contentTarget, 338 { 339 opacity: 1, 340 x: "0%", 341 width: this.actualContentExpandedWidth, 342 }, 343 0.1, 344 ); 345 } 346 347 collapse() { 348 // Only prevent double collapse on mobile 349 if (this.isMobile && (!this.expanded || this.animating)) return; 350 351 if (this.animation) this.animation.kill(); 352 this.animating = true; 353 354 if (this.isMobile) { 355 this.collapseMobile(); 356 } else { 357 this.collapseDesktop(); 358 } 359 360 this.expanded = false; 361 } 362 363 collapseMobile() { 364 this.animation = gsap.timeline({ 365 defaults: { duration: 0.3, ease: "power2.out" }, 366 onComplete: () => { 367 this.animating = false; 368 }, 369 }); 370 371 // Collapse content and container together 372 this.animation.to( 373 this.contentTarget, 374 { 375 opacity: 0, 376 height: 0, 377 overflow: "hidden", 378 }, 379 0, 380 ); 381 382 this.animation.to( 383 this.containerTarget, 384 { 385 width: this.collapsedWidth, 386 }, 387 0, 388 ); 389 390 // Reset width at end 391 this.animation.set(this.contentTarget, { width: 0 }); 392 } 393 394 collapseDesktop() { 395 const nav = this.hasNavTarget 396 ? this.navTarget 397 : this.containerTarget.querySelector("nav"); 398 399 this.animation = gsap.timeline({ 400 defaults: { duration: 0.5, ease: "power2.out" }, 401 onComplete: () => { 402 this.animating = false; 403 }, 404 }); 405 406 this.animation.to( 407 this.contentTarget, 408 { 409 opacity: 0, 410 x: "100%", 411 width: 0, 412 }, 413 0, 414 ); 415 416 this.animation.set(nav, { justifyContent: "center" }, 0.4); 417 418 this.animation.to( 419 this.containerTarget, 420 { 421 width: this.collapsedWidth, 422 paddingLeft: 16, 423 paddingRight: 16, 424 }, 425 0.3, 426 ); 427 } 428}
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.