@brandocms/jupiter 4.0.0-beta.1 → 5.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +191 -2
  2. package/package.json +20 -18
  3. package/src/index.js +10 -10
  4. package/src/modules/Application/index.js +203 -157
  5. package/src/modules/Cookies/index.js +34 -55
  6. package/src/modules/CoverOverlay/index.js +20 -13
  7. package/src/modules/Dataloader/index.js +71 -24
  8. package/src/modules/Dataloader/url-sync.js +238 -0
  9. package/src/modules/Dom/index.js +18 -0
  10. package/src/modules/DoubleHeader/index.js +571 -0
  11. package/src/modules/Dropdown/index.js +101 -75
  12. package/src/modules/EqualHeightElements/index.js +5 -7
  13. package/src/modules/EqualHeightImages/index.js +7 -2
  14. package/src/modules/FixedHeader/index.js +60 -30
  15. package/src/modules/FooterReveal/index.js +3 -3
  16. package/src/modules/HeroSlider/index.js +207 -91
  17. package/src/modules/HeroVideo/index.js +15 -27
  18. package/src/modules/Lazyload/index.js +101 -80
  19. package/src/modules/Lightbox/index.js +17 -55
  20. package/src/modules/Links/index.js +54 -49
  21. package/src/modules/Looper/index.js +1737 -0
  22. package/src/modules/Marquee/index.js +106 -37
  23. package/src/modules/MobileMenu/index.js +70 -124
  24. package/src/modules/Moonwalk/index.js +349 -150
  25. package/src/modules/Popover/index.js +186 -28
  26. package/src/modules/Popup/index.js +27 -34
  27. package/src/modules/StackedBoxes/index.js +3 -3
  28. package/src/modules/StickyHeader/index.js +364 -155
  29. package/src/modules/Toggler/index.js +184 -27
  30. package/src/utils/motion-helpers.js +330 -0
  31. package/types/index.d.ts +1 -30
  32. package/types/modules/Application/index.d.ts +6 -6
  33. package/types/modules/Breakpoints/index.d.ts +2 -0
  34. package/types/modules/Dataloader/index.d.ts +5 -2
  35. package/types/modules/Dataloader/url-sync.d.ts +36 -0
  36. package/types/modules/Dom/index.d.ts +7 -0
  37. package/types/modules/DoubleHeader/index.d.ts +63 -0
  38. package/types/modules/Dropdown/index.d.ts +7 -30
  39. package/types/modules/EqualHeightImages/index.d.ts +1 -1
  40. package/types/modules/FixedHeader/index.d.ts +1 -1
  41. package/types/modules/Lazyload/index.d.ts +9 -9
  42. package/types/modules/Lightbox/index.d.ts +0 -5
  43. package/types/modules/Looper/index.d.ts +127 -0
  44. package/types/modules/Moonwalk/index.d.ts +6 -15
  45. package/types/modules/Parallax/index.d.ts +10 -32
  46. package/types/modules/Popover/index.d.ts +12 -0
  47. package/types/modules/Popup/index.d.ts +6 -19
  48. package/types/modules/ScrollSpy/index.d.ts +1 -1
  49. package/types/modules/StickyHeader/index.d.ts +171 -14
  50. package/types/modules/Toggler/index.d.ts +24 -2
@@ -37,6 +37,7 @@ const DEFAULT_OPTIONS = {
37
37
  minSize: 40,
38
38
  updateSizes: true,
39
39
  registerCallback: true,
40
+ target: null,
40
41
  }
41
42
 
42
43
  /**
@@ -51,6 +52,20 @@ export default class Lazyload {
51
52
  constructor(app, opts = {}) {
52
53
  this.app = app
53
54
  this.opts = _defaultsDeep(opts, DEFAULT_OPTIONS)
55
+ this.target = this.opts.target || document.body
56
+ this.resizePending = new Map()
57
+ this.rafId = null
58
+
59
+ // Create reusable MutationObserver for reveal handling
60
+ this.srcsetReadyObserver = new MutationObserver(mutations => {
61
+ mutations.forEach(record => {
62
+ if (record.type === 'attributes' && record.attributeName === 'data-ll-srcset-ready') {
63
+ this.revealPicture(record.target)
64
+ this.revealObserver.unobserve(record.target)
65
+ }
66
+ })
67
+ })
68
+
54
69
  this.initialize()
55
70
 
56
71
  if (this.opts.registerCallback) {
@@ -69,35 +84,30 @@ export default class Lazyload {
69
84
  }
70
85
 
71
86
  initialize() {
72
- // initialize all images that have data-sizes="auto" and set sizes="<actual width>px"
73
- this.initializeAutoSizes()
87
+ // initialize ResizeObserver for images with data-sizes="auto"
88
+ this.initializeResizeObserver()
74
89
  // look for lazyload sections. if we find, add an observer that triggers
75
90
  // lazyload for all images within.
76
91
  this.initializeSections()
77
92
 
78
93
  // if we have native lazyload, use it.
79
- if (
80
- 'loading' in HTMLImageElement.prototype &&
81
- this.opts.useNativeLazyloadIfAvailable
82
- ) {
83
- const lazyImages = document.querySelectorAll('[data-ll-image]')
84
- lazyImages.forEach((img) => {
94
+ if ('loading' in HTMLImageElement.prototype && this.opts.useNativeLazyloadIfAvailable) {
95
+ const lazyImages = this.target.querySelectorAll('[data-ll-image]')
96
+ lazyImages.forEach(img => {
85
97
  img.setAttribute('loading', 'lazy')
86
98
  this.swapImage(img)
87
99
  })
88
100
 
89
- const lazyPictures = document.querySelectorAll('[data-ll-srcset]')
90
- lazyPictures.forEach((picture) => {
91
- picture
92
- .querySelectorAll('img')
93
- .forEach((img) => img.setAttribute('loading', 'lazy'))
101
+ const lazyPictures = this.target.querySelectorAll('[data-ll-srcset]')
102
+ lazyPictures.forEach(picture => {
103
+ picture.querySelectorAll('img').forEach(img => img.setAttribute('loading', 'lazy'))
94
104
  this.swapPicture(picture)
95
105
  })
96
106
 
97
107
  return
98
108
  }
99
109
 
100
- this.lazyPictures = document.querySelectorAll('[data-ll-srcset]')
110
+ this.lazyPictures = this.target.querySelectorAll('[data-ll-srcset]')
101
111
 
102
112
  this.loadObserver = new IntersectionObserver(
103
113
  this.handleLoadEntries.bind(this),
@@ -117,7 +127,7 @@ export default class Lazyload {
117
127
  this.opts.intersectionObserverConfig
118
128
  )
119
129
 
120
- this.lazyImages = document.querySelectorAll('[data-ll-image]')
130
+ this.lazyImages = this.target.querySelectorAll('[data-ll-image]')
121
131
  this.lazyImages.forEach((img, idx) => {
122
132
  img.setAttribute('data-ll-blurred', '')
123
133
  img.setAttribute('data-ll-idx', idx)
@@ -130,7 +140,7 @@ export default class Lazyload {
130
140
  this.lazyPictures.forEach((picture, idx) => {
131
141
  if (setAttrs) {
132
142
  picture.setAttribute('data-ll-srcset-initialized', '')
133
- picture.querySelectorAll('img:not([data-ll-loaded])').forEach((img) => {
143
+ picture.querySelectorAll('img:not([data-ll-loaded])').forEach(img => {
134
144
  img.setAttribute('data-ll-blurred', '')
135
145
  img.setAttribute('data-ll-idx', idx)
136
146
  img.style.setProperty('--ll-idx', idx)
@@ -142,45 +152,82 @@ export default class Lazyload {
142
152
 
143
153
  forceLoad($container = document.body) {
144
154
  const images = Dom.all($container, '[data-ll-image]')
145
- images.forEach((img) => this.swapImage(img))
155
+ images.forEach(img => this.swapImage(img))
146
156
 
147
157
  const pictures = Dom.all($container, '[data-ll-srcset]')
148
- pictures.forEach((picture) => this.revealPicture(picture))
158
+ pictures.forEach(picture => this.revealPicture(picture))
149
159
  }
150
160
 
151
- initializeAutoSizes() {
152
- if (this.opts.updateSizes) {
153
- this.$autoSizesImages = Dom.all('[data-sizes="auto"]')
154
- this.autoSizes()
155
- window.addEventListener(Events.APPLICATION_RESIZE, () => this.autoSizes())
161
+ initializeResizeObserver() {
162
+ if (!this.opts.updateSizes) {
163
+ return
156
164
  }
157
- }
158
165
 
159
- /**
160
- * Set sizes attribute for all imgs with `data-sizes="auto"` and source within the <picture>
161
- */
162
- autoSizes() {
163
- Array.from(this.$autoSizesImages).forEach((img) => {
164
- const width = this.getWidth(img)
165
- img.setAttribute('sizes', `${width}px`)
166
- if (img.parentNode) {
167
- Array.from(Dom.all(img.parentNode, 'source')).forEach((source) =>
168
- source.setAttribute('sizes', `${width}px`)
169
- )
170
- }
166
+ // Use ResizeObserver to watch images with data-sizes="auto"
167
+ // This eliminates layout thrashing from repeated offsetWidth reads
168
+ this.sizeObserver = new ResizeObserver(entries => {
169
+ entries.forEach(entry => {
170
+ const img = entry.target
171
+ // Use contentBoxSize for better performance (avoids layout queries)
172
+ let width = entry.borderBoxSize?.[0]?.inlineSize || entry.contentRect.width
173
+
174
+ // Round to prevent decimal fluctuations causing loops
175
+ width = Math.round(width)
176
+
177
+ // Fallback to minSize if element is too small
178
+ if (width < this.opts.minSize) {
179
+ width = this.opts.minSize
180
+ }
181
+
182
+ // Only queue update if width actually changed from current sizes attribute
183
+ const currentSizes = img.getAttribute('sizes')
184
+ const expectedSizes = `${width}px`
185
+
186
+ if (currentSizes !== expectedSizes) {
187
+ // Batch updates using RAF to avoid layout thrashing
188
+ this.resizePending.set(img, width)
189
+
190
+ if (!this.rafId) {
191
+ this.rafId = requestAnimationFrame(() => {
192
+ this.flushSizeUpdates()
193
+ })
194
+ }
195
+ }
196
+ })
197
+ })
198
+
199
+ // Observe all images with data-sizes="auto" within the target container
200
+ const autoSizesImages = Dom.all(this.target, '[data-sizes="auto"]')
201
+
202
+ // Deduplicate in case of multiple Lazyload instances
203
+ const uniqueImages = new Set(autoSizesImages)
204
+
205
+ uniqueImages.forEach(img => {
206
+ this.sizeObserver.observe(img)
171
207
  })
172
208
  }
173
209
 
174
- getWidth(img) {
175
- let width = img.offsetWidth
176
- let parent = img.parentNode
210
+ flushSizeUpdates() {
211
+ // Batch all size updates together to minimize reflows
212
+ this.resizePending.forEach((width, img) => {
213
+ const currentSizes = img.getAttribute('sizes')
214
+ const newSizes = `${Math.round(width)}px`
177
215
 
178
- while (width < this.opts.minSize && parent) {
179
- width = parent.offsetWidth
180
- parent = parent.parentNode
181
- }
216
+ // Only update if value actually changed to prevent resize loops
217
+ if (currentSizes !== newSizes) {
218
+ img.setAttribute('sizes', newSizes)
219
+ if (img.parentNode) {
220
+ Array.from(Dom.all(img.parentNode, 'source')).forEach(source => {
221
+ if (source.getAttribute('sizes') !== newSizes) {
222
+ source.setAttribute('sizes', newSizes)
223
+ }
224
+ })
225
+ }
226
+ }
227
+ })
182
228
 
183
- return width
229
+ this.resizePending.clear()
230
+ this.rafId = null
184
231
  }
185
232
 
186
233
  initializeSections() {
@@ -189,12 +236,12 @@ export default class Lazyload {
189
236
  const sectionObserver = (section, children) => {
190
237
  const imagesInSection = Dom.all(section, 'img')
191
238
  return new IntersectionObserver((entries, self) => {
192
- entries.forEach((entry) => {
239
+ entries.forEach(entry => {
193
240
  if (entry.isIntersecting || entry.intersectionRatio > 0) {
194
241
  imagesAreLoaded(imagesInSection, true).then(() => {
195
242
  dispatchElementEvent(section, Events.SECTION_LAZYLOADED)
196
243
  })
197
- children.forEach((picture) => {
244
+ children.forEach(picture => {
198
245
  this.loadPicture(picture)
199
246
  this.loadObserver.unobserve(picture)
200
247
  })
@@ -204,7 +251,7 @@ export default class Lazyload {
204
251
  }, this.opts.intersectionObserverConfig)
205
252
  }
206
253
 
207
- sections.forEach((section) => {
254
+ sections.forEach(section => {
208
255
  const children = section.querySelectorAll('picture')
209
256
  const obs = sectionObserver(section, children)
210
257
  obs.observe(section)
@@ -214,7 +261,7 @@ export default class Lazyload {
214
261
 
215
262
  // we load the picture a ways before it enters the viewport
216
263
  handleLoadEntries(elements) {
217
- elements.forEach((item) => {
264
+ elements.forEach(item => {
218
265
  if (item.isIntersecting || item.intersectionRatio > 0) {
219
266
  const picture = item.target
220
267
  this.loadPicture(picture)
@@ -225,26 +272,15 @@ export default class Lazyload {
225
272
 
226
273
  // we reveal the picture when it enters the viewport
227
274
  handleRevealEntries(elements) {
228
- const srcsetReadyObserver = new MutationObserver((mutations) => {
229
- mutations.forEach((record) => {
230
- if (
231
- record.type === 'attributes' &&
232
- record.attributeName === 'data-ll-srcset-ready'
233
- ) {
234
- this.revealPicture(record.target)
235
- this.revealObserver.unobserve(record.target)
236
- }
237
- })
238
- })
239
-
240
- elements.forEach((item) => {
275
+ elements.forEach(item => {
241
276
  if (item.isIntersecting || item.intersectionRatio > 0) {
242
277
  const picture = item.target
243
278
  const ready = item.target.hasAttribute('data-ll-srcset-ready')
244
279
  if (!ready) {
245
280
  // element is not loaded, observe the picture and wait for
246
281
  // `data-ll-srcset-ready` before revealing
247
- srcsetReadyObserver.observe(picture, { attributes: true })
282
+ // Use reusable MutationObserver to prevent memory leaks
283
+ this.srcsetReadyObserver.observe(picture, { attributes: true })
248
284
  } else {
249
285
  this.revealPicture(picture)
250
286
  this.revealObserver.unobserve(item.target)
@@ -276,23 +312,8 @@ export default class Lazyload {
276
312
  const img = picture.querySelector('img')
277
313
 
278
314
  const onload = () => {
279
- if (
280
- !img.getAttribute('data-ll-ready') &&
281
- this.app.browser === 'firefox'
282
- ) {
283
- // set sizes attribute on load again,
284
- // since firefox sometimes is a bit slow to
285
- // get the actual image width
286
- const width = this.getWidth(img)
287
-
288
- img.setAttribute('sizes', `${width}px`)
289
- if (img.parentNode) {
290
- Array.from(Dom.all(img.parentNode, 'source')).forEach((source) =>
291
- source.setAttribute('sizes', `${width}px`)
292
- )
293
- }
294
- }
295
-
315
+ // ResizeObserver now handles size updates automatically,
316
+ // including Firefox's delayed dimension calculation
296
317
  img.removeAttribute('data-ll-placeholder')
297
318
  img.removeAttribute('data-ll-blurred')
298
319
  img.removeAttribute('data-ll-loading')
@@ -336,7 +357,7 @@ export default class Lazyload {
336
357
  }
337
358
 
338
359
  lazyloadImages(elements) {
339
- elements.forEach((item) => {
360
+ elements.forEach(item => {
340
361
  if (item.isIntersecting || item.intersectionRatio > 0) {
341
362
  const image = item.target
342
363
  this.swapImage(image)
@@ -1,8 +1,8 @@
1
- import { Manager, Swipe } from '@egjs/hammerjs'
2
- import { gsap } from 'gsap/all'
1
+ import { animate } from 'motion'
3
2
  import _defaultsDeep from 'lodash.defaultsdeep'
4
3
  import imageIsLoaded from '../../utils/imageIsLoaded'
5
4
  import Dom from '../Dom'
5
+ import { set, PausedTimeline } from '../../utils/motion-helpers'
6
6
 
7
7
  /**
8
8
  * @typedef {Object} LightboxElements
@@ -16,7 +16,6 @@ import Dom from '../Dom'
16
16
  * @typedef {Object} LightboxOptions
17
17
  * @property {boolean} [captions=false] - Enable captions
18
18
  * @property {boolean} [numbers=false] - Enable index numbers
19
- * @property {boolean} [swipe=true] - Enable swipe - this breaks native zoom
20
19
  * @property {string|boolean} [trigger=false] - Selector for trigger element to open the lightbox
21
20
  * @property {LightboxElements} [elements] - Custom elements configuration
22
21
  * @property {Function} [onClick] - Click handler for lightbox
@@ -41,9 +40,6 @@ const DEFAULT_OPTIONS = {
41
40
  /* enable index numbers */
42
41
  numbers: false,
43
42
 
44
- /* enable swipe — this breaks native zoom! */
45
- swipe: true,
46
-
47
43
  /* set to a selector if you want a specific trigger element to open the box */
48
44
  trigger: false,
49
45
 
@@ -128,23 +124,17 @@ const DEFAULT_OPTIONS = {
128
124
  onOpen: (h) => {
129
125
  h.app.scrollLock()
130
126
 
131
- gsap.to(h.elements.wrapper, {
132
- duration: 0.5,
133
- opacity: 1,
134
- })
127
+ animate(h.elements.wrapper, { opacity: 1 }, { duration: 0.5 })
135
128
  },
136
129
 
137
130
  onAfterClose: () => {},
138
131
 
139
132
  onClose: (h) => {
140
133
  if (h.opts.captions) {
141
- gsap.to(h.elements.caption, {
142
- duration: 0.45,
143
- opacity: 0,
144
- })
134
+ animate(h.elements.caption, { opacity: 0 }, { duration: 0.45 })
145
135
  }
146
136
 
147
- gsap.to(
137
+ animate(
148
138
  [
149
139
  h.elements.imgWrapper,
150
140
  h.elements.nextArrow,
@@ -152,21 +142,14 @@ const DEFAULT_OPTIONS = {
152
142
  h.elements.close,
153
143
  h.elements.dots,
154
144
  ],
155
- {
156
- duration: 0.5,
157
- opacity: 0,
158
- onComplete: () => {
159
- gsap.to(h.elements.wrapper, {
160
- duration: 0.45,
161
- opacity: 0,
162
- onComplete: () => {
163
- h.app.scrollRelease()
164
- h.destroy()
165
- },
166
- })
167
- },
168
- }
169
- )
145
+ { opacity: 0 },
146
+ { duration: 0.5 }
147
+ ).finished.then(() => {
148
+ animate(h.elements.wrapper, { opacity: 0 }, { duration: 0.45 }).finished.then(() => {
149
+ h.app.scrollRelease()
150
+ h.destroy()
151
+ })
152
+ })
170
153
  },
171
154
  }
172
155
 
@@ -191,8 +174,8 @@ export default class Lightbox {
191
174
  this.firstTransition = true
192
175
  this.previousCaption = null
193
176
  this.timelines = {
194
- caption: gsap.timeline({ paused: true }),
195
- image: gsap.timeline({ paused: true }),
177
+ caption: new PausedTimeline(),
178
+ image: new PausedTimeline(),
196
179
  }
197
180
 
198
181
  this.lightboxes.forEach((lightbox) => {
@@ -294,7 +277,8 @@ export default class Lightbox {
294
277
 
295
278
  this.sections[section].forEach((img, x) => {
296
279
  const imgElement = document.createElement('img')
297
- gsap.set(imgElement, { autoAlpha: 0 })
280
+ set(imgElement, { opacity: 0 })
281
+ imgElement.style.visibility = 'hidden'
298
282
  imgElement.classList.add('lightbox-image', 'm-lg')
299
283
  imgElement.setAttribute('data-idx', x)
300
284
  this.elements.imgWrapper.appendChild(imgElement)
@@ -342,9 +326,6 @@ export default class Lightbox {
342
326
  document.body.appendChild(this.elements.wrapper)
343
327
 
344
328
  this.setImg(section, index, this.getPrevIdx(section))
345
- if (this.opts.swipe) {
346
- this.attachSwiper(section, this.elements.content, index)
347
- }
348
329
 
349
330
  this.opts.onOpen(this)
350
331
 
@@ -501,23 +482,4 @@ export default class Lightbox {
501
482
  this.opts.onPointerRight(this)
502
483
  }
503
484
  }
504
-
505
- attachSwiper(section, el, initialIdx) {
506
- const hammerManager = new Manager(el)
507
- const swipeHandler = new Swipe()
508
-
509
- this.elements.content.setAttribute('data-current-idx', initialIdx)
510
-
511
- hammerManager.add(swipeHandler)
512
-
513
- hammerManager.on('swipeleft', () => {
514
- const index = this.getNextIdx(section)
515
- this.setImg(section, index)
516
- })
517
-
518
- hammerManager.on('swiperight', () => {
519
- const index = this.getPrevIdx(section)
520
- this.setImg(section, index)
521
- })
522
- }
523
485
  }
@@ -1,7 +1,6 @@
1
- import { gsap, ScrollToPlugin } from 'gsap/all'
1
+ import { animate } from 'motion'
2
2
  import _defaultsDeep from 'lodash.defaultsdeep'
3
-
4
- gsap.registerPlugin(ScrollToPlugin)
3
+ import { set } from '../../utils/motion-helpers'
5
4
 
6
5
  /**
7
6
  * @typedef {Object} LinksOptions
@@ -23,8 +22,7 @@ const DEFAULT_OPTIONS = {
23
22
  scrollOffsetNav: false,
24
23
  mobileMenuDelay: 800,
25
24
  openExternalInWindow: true,
26
- linkQuery:
27
- 'a:not([href^="#"]):not([target="_blank"]):not([data-lightbox]):not(.noanim)',
25
+ linkQuery: 'a:not([href^="#"]):not([target="_blank"]):not([data-lightbox]):not(.noanim)',
28
26
  anchorQuery: 'a[href^="#"]:not(.noanim)',
29
27
 
30
28
  onAnchor: (target, links) => {
@@ -33,11 +31,7 @@ const DEFAULT_OPTIONS = {
33
31
  const headerHeight = header ? header.clientHeight : 0
34
32
  target = { y: target, offsetY: headerHeight }
35
33
  }
36
- links.app.scrollTo(
37
- target,
38
- links.opts.scrollDuration,
39
- links.opts.triggerEvents
40
- )
34
+ links.app.scrollTo(target, links.opts.scrollDuration, links.opts.triggerEvents)
41
35
  },
42
36
 
43
37
  onTransition: (href, app) => {
@@ -47,49 +41,40 @@ const DEFAULT_OPTIONS = {
47
41
  const fader = document.querySelector('#fader')
48
42
 
49
43
  if (fader) {
50
- gsap.set(fader, { display: 'block', opacity: 0 })
51
- gsap.to(main, {
52
- duration: 0.8,
53
- y: 25,
54
- ease: 'power3.out',
55
- })
44
+ set(fader, { display: 'block', opacity: 0 })
45
+
46
+ if (main) {
47
+ animate(main, { y: 25 }, { duration: 0.8, ease: 'easeOut' })
48
+ animate(main, { opacity: 0 }, { duration: 0.2 })
49
+ }
56
50
 
57
51
  if (header) {
58
- gsap.to(header, { duration: 0.2, opacity: 0 })
52
+ animate(header, { opacity: 0 }, { duration: 0.2 })
59
53
  }
60
54
 
61
55
  if (footer) {
62
- gsap.to(footer, { duration: 0.2, opacity: 0 })
56
+ animate(footer, { opacity: 0 }, { duration: 0.2 })
63
57
  }
64
58
 
65
- gsap.to(fader, {
66
- duration: 0.2,
67
- opacity: 1,
68
- onComplete: () => {
69
- window.location = href
70
- },
59
+ animate(fader, { opacity: 1 }, { duration: 0.2 }).finished.then(() => {
60
+ window.location = href
71
61
  })
72
62
  } else {
73
- gsap.to(main, {
74
- duration: 0.8,
75
- y: 25,
76
- ease: 'power3.out',
77
- })
63
+ if (main) {
64
+ animate(main, { y: 25 }, { duration: 0.8, ease: 'easeOut' })
65
+ animate(main, { opacity: 0 }, { duration: 0.2 })
66
+ }
78
67
 
79
68
  if (header) {
80
- gsap.to(header, { duration: 0.2, opacity: 0 })
69
+ animate(header, { opacity: 0 }, { duration: 0.2 })
81
70
  }
82
71
 
83
72
  if (footer) {
84
- gsap.to(footer, { duration: 0.2, opacity: 0 })
73
+ animate(footer, { opacity: 0 }, { duration: 0.2 })
85
74
  }
86
75
 
87
- gsap.to(main, {
88
- duration: 0.2,
89
- opacity: 0,
90
- onComplete: () => {
91
- window.location = href
92
- },
76
+ animate(main, { opacity: 0 }, { duration: 0.2 }).finished.then(() => {
77
+ window.location = href
93
78
  })
94
79
  }
95
80
  },
@@ -119,7 +104,7 @@ export default class Links {
119
104
  bindHeroLink() {
120
105
  const el = document.querySelector('[data-link-to-content]')
121
106
  if (el) {
122
- el.addEventListener('click', (e) => {
107
+ el.addEventListener('click', e => {
123
108
  const dataTarget = document.querySelector('main')
124
109
  e.preventDefault()
125
110
  if (dataTarget) {
@@ -131,8 +116,8 @@ export default class Links {
131
116
 
132
117
  bindAnchors(anchors) {
133
118
  let wait = false
134
- Array.from(anchors).forEach((anchor) => {
135
- anchor.addEventListener('click', (e) => {
119
+ Array.from(anchors).forEach(anchor => {
120
+ anchor.addEventListener('click', e => {
136
121
  e.preventDefault()
137
122
  const href = anchor.getAttribute('href')
138
123
  if (href === '#') {
@@ -187,21 +172,21 @@ export default class Links {
187
172
  bindLinks(links) {
188
173
  const loadingContainer = document.querySelector('.loading-container')
189
174
 
190
- Array.from(links).forEach((link) => {
175
+ Array.from(links).forEach(link => {
191
176
  const href = link.getAttribute('href')
192
177
  if (!href || href === '#' || href.startsWith('javascript:')) {
193
178
  return // Skip empty, anchor, or JS-based links
194
179
  }
195
180
 
196
181
  // Determine the normalized hostname of the current document.
197
- const normalizedCurrentHost = this.normalizeHostname(
198
- document.location.hostname
199
- )
182
+ const normalizedCurrentHost = this.normalizeHostname(document.location.hostname)
200
183
 
201
184
  // For absolute URLs, use the URL constructor.
202
185
  let linkHostname
186
+ let linkUrl
203
187
  try {
204
- linkHostname = new URL(href, document.location.href).hostname
188
+ linkUrl = new URL(href, document.location.href)
189
+ linkHostname = linkUrl.hostname
205
190
  } catch (error) {
206
191
  // If URL construction fails, assume it's not internal.
207
192
  console.warn(`Failed to parse URL for href "${href}":`, error) // Log errors for debugging
@@ -216,7 +201,7 @@ export default class Links {
216
201
  link.setAttribute('target', '_blank')
217
202
  }
218
203
 
219
- link.addEventListener('click', (e) => {
204
+ link.addEventListener('click', e => {
220
205
  if (e.shiftKey || e.metaKey || e.ctrlKey) {
221
206
  return
222
207
  }
@@ -225,9 +210,29 @@ export default class Links {
225
210
  loadingContainer.style.display = 'none'
226
211
  }
227
212
 
228
- if (internalLink) {
229
- e.preventDefault()
230
- this.opts.onTransition(href, this.app)
213
+ if (internalLink && linkUrl) {
214
+ // Check if we're navigating to the same page with just a different hash
215
+ const currentUrl = new URL(window.location.href)
216
+ const isSamePage = linkUrl.pathname === currentUrl.pathname &&
217
+ linkUrl.search === currentUrl.search
218
+
219
+ if (isSamePage && linkUrl.hash) {
220
+ // Same page, just different hash - treat as anchor navigation
221
+ e.preventDefault()
222
+ const target = linkUrl.hash
223
+ const element = document.querySelector(target)
224
+ if (element) {
225
+ this.opts.onAnchor(element, this)
226
+ history.pushState({}, '', href)
227
+ }
228
+ } else if (isSamePage && !linkUrl.hash && !currentUrl.hash) {
229
+ // Same exact page without hash - do nothing
230
+ e.preventDefault()
231
+ } else {
232
+ // Different page or same page with/without hash change - do transition
233
+ e.preventDefault()
234
+ this.opts.onTransition(href, this.app)
235
+ }
231
236
  }
232
237
  })
233
238
  })