@brandocms/jupiter 4.0.0-beta.2 → 5.0.0-beta.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brandocms/jupiter",
3
- "version": "4.0.0-beta.2",
3
+ "version": "5.0.0-beta.10",
4
4
  "description": "Frontend helpers.",
5
5
  "author": "Univers/Twined",
6
6
  "license": "UNLICENSED",
@@ -36,23 +36,21 @@
36
36
  "playwright:dataloader": "playwright test e2e/dataloader.spec.js --project=chromium --reporter line",
37
37
  "playwright:dataloader-url-sync": "playwright test e2e/dataloader-url-sync.spec.js --project=chromium --reporter line",
38
38
  "playwright:parallax": "playwright test e2e/parallax.spec.js --project=chromium --reporter line",
39
+ "playwright:looper": "playwright test e2e/looper.spec.js --project=chromium --reporter line",
39
40
  "vite": "vite",
40
41
  "vite:build": "vite build",
41
42
  "vite:preview": "vite preview"
42
43
  },
43
44
  "types": "types/index.d.ts",
44
45
  "dependencies": {
45
- "body-scroll-lock": "^4.0.0-beta.0",
46
- "gsap": "^3.13.0",
47
46
  "lodash.defaultsdeep": "^4.6.1",
48
- "motion": "^12.23.24",
49
- "virtual-scroll": "^2.2.1"
47
+ "motion": "^12.35.1"
50
48
  },
51
49
  "devDependencies": {
52
- "@playwright/test": "^1.52.0",
53
- "@types/node": "^22.15.16",
54
- "typescript": "^5.8.3",
55
- "vite": "^6.3.5"
50
+ "@playwright/test": "^1.57.0",
51
+ "@types/node": "^22.19.3",
52
+ "typescript": "^5.9.3",
53
+ "vite": "^6.4.1"
56
54
  },
57
55
  "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
58
56
  }
@@ -3,6 +3,91 @@ import _defaultsDeep from 'lodash.defaultsdeep'
3
3
  import * as Events from '../../events'
4
4
  import { set } from '../../utils/motion-helpers'
5
5
 
6
+ /**
7
+ * @module Cookies
8
+ *
9
+ * Cookie consent module with optional dialog and toggle button support.
10
+ *
11
+ * ## Consent dialog
12
+ *
13
+ * The traditional consent banner uses a fixed container at the bottom of the page:
14
+ *
15
+ * ```html
16
+ * <div class="cookie-container">
17
+ * <div class="cookie-container-inner">
18
+ * <div class="cookie-law-text">
19
+ * <p>We use cookies...</p>
20
+ * </div>
21
+ * <div class="cookie-law-buttons">
22
+ * <button class="dismiss-cookielaw">Accept</button>
23
+ * <button class="refuse-cookielaw">Decline</button>
24
+ * </div>
25
+ * </div>
26
+ * </div>
27
+ * ```
28
+ *
29
+ * ## Consent toggle button
30
+ *
31
+ * A toggle button can be placed anywhere on the page to let users change their
32
+ * consent at any time. It can also serve as the sole consent mechanism (no
33
+ * dialog required).
34
+ *
35
+ * ```html
36
+ * <button data-cookie-consent
37
+ * data-cookie-consent-accept="Accept cookies"
38
+ * data-cookie-consent-refuse="Refuse cookies">
39
+ * </button>
40
+ * ```
41
+ *
42
+ * ### Data attributes
43
+ *
44
+ * | Attribute | Description |
45
+ * |---|---|
46
+ * | `data-cookie-consent` | Marks the element as a consent toggle |
47
+ * | `data-cookie-consent-accept` | Label shown when user can accept (currently refused/unset) |
48
+ * | `data-cookie-consent-refuse` | Label shown when user can retract (currently accepted) |
49
+ * | `data-cookie-consent-status` | Set by the module: `"accepted"` or `"refused"` |
50
+ * | `data-cookie-consent-icon` | Set on the injected icon `<span>` |
51
+ * | `data-cookie-consent-label` | Set on the injected label `<span>` |
52
+ *
53
+ * ### CSS styling
54
+ *
55
+ * ```css
56
+ * [data-cookie-consent-status="accepted"] [data-cookie-consent-icon] { color: green; }
57
+ * [data-cookie-consent-status="refused"] [data-cookie-consent-icon] { color: red; }
58
+ * ```
59
+ *
60
+ * ### Gettext / translation
61
+ *
62
+ * ```html
63
+ * <button data-cookie-consent
64
+ * data-cookie-consent-accept="{{ _('Accept cookies') }}"
65
+ * data-cookie-consent-refuse="{{ _('Refuse cookies') }}">
66
+ * </button>
67
+ * ```
68
+ *
69
+ * ## Usage examples
70
+ *
71
+ * Dialog only (default):
72
+ * ```js
73
+ * new Cookies(app)
74
+ * ```
75
+ *
76
+ * Toggle only (no dialog HTML needed):
77
+ * ```js
78
+ * new Cookies(app, { setCookies: (c) => { ... } })
79
+ * ```
80
+ *
81
+ * Both dialog and toggle:
82
+ * ```js
83
+ * new Cookies(app, {
84
+ * onConsentChanged: (c) => {
85
+ * console.log(c.getCookie('COOKIES_CONSENT_STATUS'))
86
+ * }
87
+ * })
88
+ * ```
89
+ */
90
+
6
91
  /**
7
92
  * @typedef {Object} CookiesOptions
8
93
  * @property {Function} [onAccept] - Called when cookies are accepted
@@ -11,6 +96,7 @@ import { set } from '../../utils/motion-helpers'
11
96
  * @property {Function} [alreadyRefused] - Called if user has already refused cookies
12
97
  * @property {Function} [setCookies] - Custom function to set cookies
13
98
  * @property {Function} [showCC] - Custom function to display cookie consent dialog
99
+ * @property {Function} [onConsentChanged] - Called after consent is toggled via the toggle button
14
100
  */
15
101
 
16
102
  /** @type {CookiesOptions} */
@@ -21,6 +107,7 @@ const DEFAULT_OPTIONS = {
21
107
 
22
108
  c.setCookie('COOKIES_CONSENT_STATUS', 1, oneYearFromNow, '/')
23
109
  c.opts.setCookies(c)
110
+ c.updateConsentToggles()
24
111
 
25
112
  const timeline = [
26
113
  [c.cc, { y: '120%' }, { duration: 0.35, ease: 'easeIn', at: 0 }],
@@ -37,6 +124,7 @@ const DEFAULT_OPTIONS = {
37
124
  oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1)
38
125
 
39
126
  c.setCookie('COOKIES_CONSENT_STATUS', 0, oneYearFromNow, '/')
127
+ c.updateConsentToggles()
40
128
 
41
129
  const timeline = [
42
130
  [c.cc, { y: '120%' }, { duration: 0.35, ease: 'easeIn', at: 0 }],
@@ -58,6 +146,8 @@ const DEFAULT_OPTIONS = {
58
146
 
59
147
  setCookies: (c) => {},
60
148
 
149
+ onConsentChanged: (c) => {},
150
+
61
151
  showCC: (c) => {
62
152
  if (c.hasCookie('COOKIES_CONSENT_STATUS')) {
63
153
  if (c.getCookie('COOKIES_CONSENT_STATUS') === '1') {
@@ -107,22 +197,114 @@ export default class Cookies {
107
197
  this.btn = document.querySelector('.dismiss-cookielaw')
108
198
  this.btnRefuse = document.querySelector('.refuse-cookielaw')
109
199
 
110
- if (!this.btn) {
200
+ this.setupConsentToggles()
201
+
202
+ if (!this.btn && this.consentToggles.length === 0) {
111
203
  return
112
204
  }
113
205
 
114
- this.app.registerCallback(Events.APPLICATION_REVEALED, () => {
115
- this.opts.showCC(this)
206
+ if (this.btn) {
207
+ this.app.registerCallback(Events.APPLICATION_REVEALED, () => {
208
+ this.opts.showCC(this)
209
+ })
210
+
211
+ this.btn.addEventListener('click', () => {
212
+ this.opts.onAccept(this)
213
+ })
214
+ if (this.btnRefuse) {
215
+ this.btnRefuse.addEventListener('click', () => {
216
+ this.opts.onRefuse(this)
217
+ })
218
+ }
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Find all `[data-cookie-consent]` elements and wire them up.
224
+ */
225
+ setupConsentToggles() {
226
+ this.consentToggles = [...document.querySelectorAll('[data-cookie-consent]')]
227
+
228
+ this.consentToggles.forEach(el => {
229
+ const icon = document.createElement('span')
230
+ icon.setAttribute('data-cookie-consent-icon', '')
231
+
232
+ const label = document.createElement('span')
233
+ label.setAttribute('data-cookie-consent-label', '')
234
+
235
+ el.appendChild(icon)
236
+ el.appendChild(label)
237
+
238
+ this.updateConsentToggle(el)
239
+
240
+ el.addEventListener('click', () => {
241
+ this.handleConsentToggle()
242
+ })
116
243
  })
244
+ }
117
245
 
118
- this.btn.addEventListener('click', () => {
119
- this.opts.onAccept(this)
246
+ /**
247
+ * Update a single consent toggle element to reflect current cookie state.
248
+ * @param {Element} el - The toggle element
249
+ */
250
+ updateConsentToggle(el) {
251
+ const accepted = this.getCookie('COOKIES_CONSENT_STATUS') === '1'
252
+ const acceptText = el.getAttribute('data-cookie-consent-accept') || 'Accept cookies'
253
+ const refuseText = el.getAttribute('data-cookie-consent-refuse') || 'Refuse cookies'
254
+
255
+ const icon = el.querySelector('[data-cookie-consent-icon]')
256
+ const label = el.querySelector('[data-cookie-consent-label]')
257
+
258
+ if (accepted) {
259
+ el.setAttribute('data-cookie-consent-status', 'accepted')
260
+ icon.textContent = '\u2713'
261
+ label.textContent = refuseText
262
+ } else {
263
+ el.setAttribute('data-cookie-consent-status', 'refused')
264
+ icon.textContent = '\u2715'
265
+ label.textContent = acceptText
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Update all consent toggle elements.
271
+ */
272
+ updateConsentToggles() {
273
+ this.consentToggles.forEach(el => {
274
+ this.updateConsentToggle(el)
120
275
  })
121
- if (this.btnRefuse) {
122
- this.btnRefuse.addEventListener('click', () => {
123
- this.opts.onRefuse(this)
276
+ }
277
+
278
+ /**
279
+ * Handle a click on a consent toggle button.
280
+ */
281
+ handleConsentToggle() {
282
+ const oneYearFromNow = new Date()
283
+ oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1)
284
+
285
+ const accepted = this.getCookie('COOKIES_CONSENT_STATUS') === '1'
286
+
287
+ if (accepted) {
288
+ this.setCookie('COOKIES_CONSENT_STATUS', 0, oneYearFromNow, '/')
289
+ } else {
290
+ this.setCookie('COOKIES_CONSENT_STATUS', 1, oneYearFromNow, '/')
291
+ this.opts.setCookies(this)
292
+ }
293
+
294
+ this.updateConsentToggles()
295
+
296
+ if (this.cc && this.cc.style.display !== 'none') {
297
+ const timeline = [
298
+ [this.cc, { y: '120%' }, { duration: 0.35, ease: 'easeIn', at: 0 }],
299
+ [this.inner, { opacity: 0 }, { duration: 0.3, ease: 'easeIn', at: 0 }]
300
+ ]
301
+
302
+ animate(timeline).finished.then(() => {
303
+ this.cc.style.display = 'none'
124
304
  })
125
305
  }
306
+
307
+ this.opts.onConsentChanged(this)
126
308
  }
127
309
 
128
310
  /**
@@ -50,7 +50,7 @@ const DEFAULT_EVENTS = {
50
50
 
51
51
  onPin: (h) => {
52
52
  animate(h.auxEl, {
53
- yPercent: '0'
53
+ y: '0%'
54
54
  }, {
55
55
  duration: 0.35,
56
56
  ease: 'easeOut'
@@ -60,7 +60,7 @@ const DEFAULT_EVENTS = {
60
60
  onUnpin: (h) => {
61
61
  h._hiding = true
62
62
  animate(h.auxEl, {
63
- yPercent: '-100'
63
+ y: '-100%'
64
64
  }, {
65
65
  duration: 0.25,
66
66
  ease: 'easeIn'
@@ -86,12 +86,12 @@ const DEFAULT_OPTIONS = {
86
86
  },
87
87
  enter: (h) => {
88
88
  // Set initial states
89
- set(h.auxEl, { yPercent: -100 })
89
+ set(h.auxEl, { y: '-100%' })
90
90
  set(h.lis, { opacity: 0 })
91
91
 
92
92
  // Auxiliary header slides down
93
93
  animate(h.auxEl, {
94
- yPercent: 0
94
+ y: '0%'
95
95
  }, {
96
96
  duration: 1,
97
97
  delay: h.opts.enterDelay,
@@ -79,7 +79,7 @@ import { set } from '../../utils/motion-helpers'
79
79
  const DEFAULT_EVENTS = {
80
80
  onPin: (h) => {
81
81
  animate(h.el, {
82
- yPercent: '0'
82
+ y: '0%'
83
83
  }, {
84
84
  duration: 0.35,
85
85
  ease: 'easeOut'
@@ -89,7 +89,7 @@ const DEFAULT_EVENTS = {
89
89
  onUnpin: (h) => {
90
90
  h._hiding = true
91
91
  animate(h.el, {
92
- yPercent: '-100'
92
+ y: '-100%'
93
93
  }, {
94
94
  duration: 0.25,
95
95
  ease: 'easeIn'
@@ -156,14 +156,14 @@ const DEFAULT_OPTIONS = {
156
156
  canvas: window,
157
157
  intersects: null,
158
158
  beforeEnter: (h) => {
159
- set(h.el, { yPercent: -100 })
159
+ set(h.el, { y: '-100%' })
160
160
  set(h.lis, { opacity: 0 })
161
161
  },
162
162
 
163
163
  enter: (h) => {
164
164
  // Header slides down
165
165
  animate(h.el, {
166
- yPercent: 0
166
+ y: '0%'
167
167
  }, {
168
168
  duration: 1,
169
169
  delay: h.opts.enterDelay,
@@ -306,20 +306,30 @@ export default class HeroSlider {
306
306
  this._currentAnimation = animate(sequence)
307
307
 
308
308
  this._currentAnimation.finished.then(() => {
309
- // Cleanup and shuffle z-indexes
310
- set(this._nextSlide, { zIndex: this.opts.zIndex.next, opacity: 1 })
311
- set(this._currentSlide, {
312
- zIndex: this.opts.zIndex.visible,
313
- width: '100%',
314
- opacity: 1,
309
+ // Reset ALL slides using instant animations to ensure Motion.js state is clean
310
+ Array.from(this.slides).forEach((slide) => {
311
+ if (slide === this._currentSlide) return
312
+ const img = slide.querySelector('.hero-slide-img')
313
+ if (img) {
314
+ // Use animate with duration 0 to reset Motion.js internal state
315
+ animate(img, { scale: 1 }, { duration: 0 })
316
+ }
317
+ const isNext = slide === this._nextSlide
318
+ // Reset slide using animate with duration 0
319
+ animate(slide, {
320
+ width: '100%',
321
+ opacity: isNext ? 1 : 0,
322
+ }, { duration: 0 })
323
+ slide.style.overflow = ''
324
+ slide.style.zIndex = isNext ? this.opts.zIndex.next : this.opts.zIndex.regular
315
325
  })
316
- set(this._previousSlide, {
317
- zIndex: this.opts.zIndex.regular,
326
+
327
+ animate(this._currentSlide, {
318
328
  width: '100%',
319
- opacity: 0, // Hide previous slide
320
- })
321
- // Reset previous slide image scale for next time
322
- set(previousSlideImg, { scale: 1.0 })
329
+ opacity: 1,
330
+ }, { duration: 0 })
331
+ this._currentSlide.style.zIndex = this.opts.zIndex.visible
332
+
323
333
  this.next()
324
334
  })
325
335
  }
@@ -83,6 +83,65 @@ export default class Lazyload {
83
83
  this.initObserver(this.revealObserver, false)
84
84
  }
85
85
 
86
+ /**
87
+ * Observe new lazyload elements within a container
88
+ * Handles both [data-ll-image] and [data-ll-srcset] elements
89
+ * Useful for dynamically added content (e.g., Looper clones)
90
+ * @param {HTMLElement|HTMLElement[]|NodeList} elements - Container element(s) or lazyload element(s) to observe
91
+ */
92
+ observe(elements) {
93
+ // Handle NodeList, array, or single element
94
+ const els = elements instanceof NodeList ? Array.from(elements) :
95
+ Array.isArray(elements) ? elements : [elements]
96
+
97
+ let imgIdx = this.lazyImages?.length || 0
98
+ let picIdx = this.lazyPictures?.length || 0
99
+
100
+ els.forEach(el => {
101
+ // Handle [data-ll-image] elements
102
+ if (this.imageObserver) {
103
+ const images = el.matches?.('[data-ll-image]')
104
+ ? [el]
105
+ : el.querySelectorAll?.('[data-ll-image]') || []
106
+
107
+ images.forEach(img => {
108
+ // Skip if already observed or loaded
109
+ if (img.hasAttribute('data-ll-idx') || img.hasAttribute('data-ll-loaded')) return
110
+
111
+ img.setAttribute('data-ll-blurred', '')
112
+ img.setAttribute('data-ll-idx', imgIdx)
113
+ img.style.setProperty('--ll-idx', imgIdx)
114
+ this.imageObserver.observe(img)
115
+ imgIdx++
116
+ })
117
+ }
118
+
119
+ // Handle [data-ll-srcset] picture elements
120
+ if (this.loadObserver) {
121
+ const pictures = el.matches?.('[data-ll-srcset]')
122
+ ? [el]
123
+ : el.querySelectorAll?.('[data-ll-srcset]') || []
124
+
125
+ pictures.forEach(picture => {
126
+ // Skip if already loaded
127
+ if (picture.hasAttribute('data-ll-srcset-ready')) return
128
+
129
+ picture.setAttribute('data-ll-srcset-initialized', '')
130
+ picture.querySelectorAll('img:not([data-ll-loaded])').forEach(img => {
131
+ img.removeAttribute('data-ll-idx') // Clear cloned idx
132
+ img.setAttribute('data-ll-blurred', '')
133
+ img.setAttribute('data-ll-idx', picIdx)
134
+ img.style.setProperty('--ll-idx', picIdx)
135
+ })
136
+ // Add to both observers like initObserver does
137
+ this.loadObserver.observe(picture)
138
+ this.revealObserver?.observe(picture)
139
+ picIdx++
140
+ })
141
+ }
142
+ })
143
+ }
144
+
86
145
  initialize() {
87
146
  // initialize ResizeObserver for images with data-sizes="auto"
88
147
  this.initializeResizeObserver()
@@ -128,12 +187,7 @@ export default class Lazyload {
128
187
  )
129
188
 
130
189
  this.lazyImages = this.target.querySelectorAll('[data-ll-image]')
131
- this.lazyImages.forEach((img, idx) => {
132
- img.setAttribute('data-ll-blurred', '')
133
- img.setAttribute('data-ll-idx', idx)
134
- img.style.setProperty('--ll-idx', idx)
135
- this.imageObserver.observe(img)
136
- })
190
+ this.observe(this.lazyImages)
137
191
  }
138
192
 
139
193
  initObserver(observer, setAttrs = true) {
@@ -150,12 +204,17 @@ export default class Lazyload {
150
204
  })
151
205
  }
152
206
 
153
- forceLoad($container = document.body) {
207
+ forceLoad($container = document.body, { reveal = true } = {}) {
154
208
  const images = Dom.all($container, '[data-ll-image]')
155
209
  images.forEach(img => this.swapImage(img))
156
210
 
157
211
  const pictures = Dom.all($container, '[data-ll-srcset]')
158
- pictures.forEach(picture => this.revealPicture(picture))
212
+ pictures.forEach(picture => {
213
+ this.loadPicture(picture)
214
+ if (reveal) {
215
+ this.revealPicture(picture)
216
+ }
217
+ })
159
218
  }
160
219
 
161
220
  initializeResizeObserver() {