PageSourceSearch

https://slowdownpractice.com/assets/controllers/dynamic_island_controller-52d26421.js

js slowdownpractice.com collected 2026-10-02 00:56:27 UTC 12,918 bytes, 428 lines download raw bytes

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.