@brandocms/jupiter 5.0.0-beta.17 → 5.0.0-beta.19

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/README.md CHANGED
@@ -949,6 +949,10 @@ when the original header is visible.
949
949
  - Pin header when scroll is forced (`application.scrollTo`, clicking anchors etc)
950
950
  - `unPinOnResize` - default `false`
951
951
  - Unpin header when window is resized
952
+ - `headerHeightTracksPin` - default `true`
953
+ - Set per section (under `default` / `sections`). Whether `--header-height` drops
954
+ to `0px` while the header is unpinned. Set `false` for a header that never
955
+ retracts — see [CSS variables](#css-variables) under FixedHeader.
952
956
 
953
957
  - Events
954
958
  - `onMainVisible`
@@ -978,7 +982,8 @@ its space is still reserved.
978
982
 
979
983
  ### Options
980
984
 
981
- Same options as FixedHeader - see FixedHeader documentation below.
985
+ Same options as FixedHeader - see FixedHeader documentation below, including
986
+ `headerHeightTracksPin` and the [CSS variables](#css-variables) section.
982
987
 
983
988
 
984
989
  ## FixedHeader
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brandocms/jupiter",
3
- "version": "5.0.0-beta.17",
3
+ "version": "5.0.0-beta.19",
4
4
  "description": "Frontend helpers.",
5
5
  "author": "Univers/Twined",
6
6
  "license": "UNLICENSED",
@@ -80,6 +80,7 @@ const DEFAULT_OPTIONS = {
80
80
 
81
81
  default: {
82
82
  onClone: (h) => h.el.cloneNode(true),
83
+ headerHeightTracksPin: true,
83
84
  canvas: window,
84
85
  beforeEnter: (h) => {
85
86
  set(h.el, { opacity: 0 })
@@ -450,11 +451,25 @@ export default class DoubleHeader {
450
451
 
451
452
  /**
452
453
  * Update the --header-height CSS variable on :root.
453
- * Uses el height when pinned (el is the main header, auxEl is secondary).
454
- * Set to 0px when unpinned.
454
+ *
455
+ * Measured from `el`, the main header — `auxEl` is the clone. By default this
456
+ * is how much header is *visible*, the measured height when pinned and 0 when
457
+ * unpinned, so anything positioned under the bar follows it out of the way as
458
+ * the clone retracts.
459
+ *
460
+ * That is wrong for a header configured never to retract, whether through
461
+ * `preventUnpin` or by no-opping `onPin` / `onUnpin`. The bar stays put, but
462
+ * `_pinned` still flips on every change of scroll direction, so the variable
463
+ * drops to 0 and back while nothing moves. Any layout sized from it then grows
464
+ * and shrinks the document under the reader. Scroll anchoring absorbs that
465
+ * mid-page, but not at the very bottom, where the scroll offset is clamped to
466
+ * the document and the page visibly jumps instead.
467
+ *
468
+ * `headerHeightTracksPin: false` publishes the measured height throughout.
455
469
  */
456
470
  _updateHeaderHeight() {
457
- const height = this._pinned ? `${this.el.clientHeight}px` : '0px'
471
+ const tracksPin = this.opts.headerHeightTracksPin !== false
472
+ const height = tracksPin && !this._pinned ? '0px' : `${this.el.clientHeight}px`
458
473
  document.documentElement.style.setProperty('--header-height', height)
459
474
  }
460
475
 
@@ -57,6 +57,12 @@ const DEFAULT_OPTIONS = {
57
57
  snapDuration: 0.5, // Duration of snap animation in seconds (0.3-1.0, lower = faster/snappier)
58
58
  snapBounce: 0.15, // Spring bounce amount (0-1, 0 = no bounce, higher = more bouncy)
59
59
 
60
+ // Edge spring (loop: false) — a throw past either end keeps its speed, runs
61
+ // past the edge and springs back, instead of gliding in slowly
62
+ edgeStiffness: 260, // Pull back towards the edge (higher = shorter overshoot)
63
+ edgeDamping: 29, // Damping of the pull (29 at 260 is just under critical: one overshoot, no wobble)
64
+ edgeElasticity: 0.55, // Dragging past an end follows the finger with this much give (0 = hard stop at the edge)
65
+
60
66
  speed: {
61
67
  sm: 0.1, // Speed for mobile (multiplier)
62
68
  lg: 0.35, // Speed for desktop (multiplier)
@@ -910,7 +916,9 @@ function horizontalLoop(app, items, config) {
910
916
  indexSetByNav = false
911
917
  startX = e.clientX
912
918
  startY = e.clientY
913
- startPosition = position.get()
919
+ // Grabbed past an end (mid-bounce): continue from the drag distance that
920
+ // shows the row there, or the rubber band would jump
921
+ startPosition = shouldLoop ? position.get() : unstretch(position.get())
914
922
  velocityTracker = [{ x: e.clientX, time: e.timeStamp }]
915
923
  hasDragged = false // Reset - will be set true if movement exceeds threshold
916
924
  axisDecided = false // Reset - will be decided on first significant movement
@@ -1030,9 +1038,8 @@ function horizontalLoop(app, items, config) {
1030
1038
  // For looping, position grows freely - items wrap as groups
1031
1039
  position.set(newPosition)
1032
1040
  } else {
1033
- // For non-looping, clamp position to maxScrollPosition (last item at right edge)
1034
- const clampedPos = Math.max(0, Math.min(maxScrollPosition, newPosition))
1035
- position.set(clampedPos)
1041
+ // For non-looping, past either end the row gives with resistance
1042
+ position.set(stretch(newPosition))
1036
1043
  }
1037
1044
  }
1038
1045
 
@@ -1093,6 +1100,18 @@ function horizontalLoop(app, items, config) {
1093
1100
  if (overallDelta > 0 && velocity < 0) velocity = 0
1094
1101
  if (overallDelta < 0 && velocity > 0) velocity = 0
1095
1102
 
1103
+ // Released past an end: always spring back to it, whatever the velocity
1104
+ if (!shouldLoop && outOfBounds(position.get())) {
1105
+ inertiaAnimation = throwPastEdge(-velocity * config.throwVelocityMultiplier)
1106
+ inertiaAnimation
1107
+ .then(() => {
1108
+ inertiaAnimation = null
1109
+ updateIndexDisplay()
1110
+ })
1111
+ .catch(() => { inertiaAnimation = null })
1112
+ return
1113
+ }
1114
+
1096
1115
  // If snap is enabled, always use it (GSAP-style: snap modifies inertia target)
1097
1116
  // Otherwise use old logic: inertia if velocity, or resume crawl
1098
1117
  if (config.snap) {
@@ -1124,6 +1143,15 @@ function horizontalLoop(app, items, config) {
1124
1143
  const velocityMultiplier = reducedMotion ? config.throwVelocityMultiplier * 0.3 : config.throwVelocityMultiplier
1125
1144
  const motionVelocity = -velocity * velocityMultiplier
1126
1145
 
1146
+ // A throw that would land past either end springs off the edge instead
1147
+ if (!reducedMotion && throwsPastEdge(currentPos, motionVelocity)) {
1148
+ inertiaAnimation = throwPastEdge(motionVelocity)
1149
+ inertiaAnimation
1150
+ .then(() => { inertiaAnimation = null })
1151
+ .catch(() => { inertiaAnimation = null })
1152
+ return
1153
+ }
1154
+
1127
1155
  // Estimate target for Motion.js (it recomputes internally for type: 'inertia',
1128
1156
  // but we pass a reasonable target to avoid a zero-distance animation)
1129
1157
  const power = config.throwPower
@@ -1162,6 +1190,142 @@ function horizontalLoop(app, items, config) {
1162
1190
  })
1163
1191
  }
1164
1192
 
1193
+ function outOfBounds(pos) {
1194
+ return pos > maxScrollPosition || pos < 0
1195
+ }
1196
+
1197
+ /**
1198
+ * Rubber band for dragging past an end of a non-looping row — the curve
1199
+ * iOS uses, (1 - 1 / (x·c/d + 1)) · d: the row follows the pointer at
1200
+ * `edgeElasticity` of its speed at the edge, slower the further out, and
1201
+ * never further than its own width. `stretch` maps drag distance to the
1202
+ * shown position, `unstretch` inverts it, `stretchRate` is the slope.
1203
+ */
1204
+ function stretch(raw) {
1205
+ const c = config.edgeElasticity
1206
+ if (!c) return Math.max(0, Math.min(maxScrollPosition, raw))
1207
+ const d = containerWidth || container.offsetWidth
1208
+ const band = over => (1 - 1 / ((over * c) / d + 1)) * d
1209
+ if (raw > maxScrollPosition) return maxScrollPosition + band(raw - maxScrollPosition)
1210
+ if (raw < 0) return -band(-raw)
1211
+ return raw
1212
+ }
1213
+
1214
+ function unstretch(shown) {
1215
+ const c = config.edgeElasticity
1216
+ if (!c || !outOfBounds(shown)) return shown
1217
+ const d = containerWidth || container.offsetWidth
1218
+ const over = shown > maxScrollPosition ? shown - maxScrollPosition : -shown
1219
+ const raw = (d / c) * (1 / (1 - Math.min(over, d - 1) / d) - 1)
1220
+ return shown > maxScrollPosition ? maxScrollPosition + raw : -raw
1221
+ }
1222
+
1223
+ function stretchRate(shown) {
1224
+ const c = config.edgeElasticity
1225
+ if (!c || !outOfBounds(shown)) return 1
1226
+ const d = containerWidth || container.offsetWidth
1227
+ const over = shown > maxScrollPosition ? shown - maxScrollPosition : -shown
1228
+ // dy/dx = c / (x·c/d + 1)², written in terms of the shown overshoot
1229
+ return c * (1 - over / d) ** 2
1230
+ }
1231
+
1232
+ /**
1233
+ * Whether a throw would come to rest past either end of a non-looping row.
1234
+ * Uses Motion's own inertia arithmetic: it rests at origin + power * velocity.
1235
+ */
1236
+ function throwsPastEdge(currentPos, motionVelocity) {
1237
+ if (shouldLoop) return false
1238
+ if (outOfBounds(currentPos)) return true
1239
+ const ideal = currentPos + config.throwPower * motionVelocity
1240
+ return ideal > maxScrollPosition || ideal < 0
1241
+ }
1242
+
1243
+ /**
1244
+ * Throw into an end of a non-looping row: keep the speed, reach the edge,
1245
+ * run past it and spring back.
1246
+ *
1247
+ * The plain inertia animations clamp their target to the row (snap via
1248
+ * `modifyTarget`), and Motion then rescales the whole throw to that shorter
1249
+ * distance — so a hard throw near the end crawled the last stretch on the
1250
+ * exponential tail, two seconds of it. Motion's own `min`/`max` bounce
1251
+ * cannot help: the clamped target never reaches the boundary, and its
1252
+ * spring is handed a velocity in px/ms rather than px/s (see the TODO in
1253
+ * motion-dom's inertia generator), so it barely overshoots when it does.
1254
+ *
1255
+ * Motion's inertia is closed-form — x(t) = target - A·e^(-t/τ), with
1256
+ * A = power · velocity and target = origin + A — so the moment it crosses
1257
+ * the edge, and its speed there, are known up front. Phase one is a tween
1258
+ * to the edge whose easing *is* that curve; phase two a spring to the edge
1259
+ * starting at that speed, which carries it past and back.
1260
+ *
1261
+ * Not a listener on the value: starting the spring from inside the decay's
1262
+ * own `change` notification left the value NaN (the decay was mid-frame),
1263
+ * and the row froze with every later drag computing from NaN.
1264
+ *
1265
+ * Returns Motion-like controls — `stop()` and a thenable — so
1266
+ * `onPointerDown` interrupts it like any other animation.
1267
+ */
1268
+ function throwPastEdge(motionVelocity) {
1269
+ const origin = position.get()
1270
+ // Already past an end (released mid-stretch): back to that end, starting
1271
+ // at the speed the row is actually moving on screen — the pointer's,
1272
+ // slowed by the rubber band.
1273
+ const outside = outOfBounds(origin)
1274
+ const edge = outside
1275
+ ? (origin > maxScrollPosition ? maxScrollPosition : 0)
1276
+ : (motionVelocity > 0 ? maxScrollPosition : 0)
1277
+ findNearestSnapPoint(edge) // lands on the last (or first) item: keep the index in step
1278
+
1279
+ const tau = config.throwResistance // ms
1280
+ const amplitude = config.throwPower * motionVelocity
1281
+ const beyond = origin + amplitude - edge // how far past the edge the decay would rest
1282
+ // Time (ms) the decay takes to reach the edge, and its speed there (px/s)
1283
+ const tEdge = outside ? 0 : Math.max(0, -tau * Math.log(beyond / amplitude))
1284
+ const edgeVelocity = outside
1285
+ ? motionVelocity * stretchRate(origin)
1286
+ : (beyond / tau) * 1000
1287
+
1288
+ let current = null
1289
+ let stopped = false
1290
+ let resolve
1291
+ const done = new Promise(r => { resolve = r })
1292
+
1293
+ const bounce = () => {
1294
+ if (stopped) return
1295
+ current = animate(position, edge, {
1296
+ type: 'spring',
1297
+ velocity: edgeVelocity,
1298
+ stiffness: config.edgeStiffness,
1299
+ damping: config.edgeDamping,
1300
+ restSpeed: 10,
1301
+ restDelta: 0.5,
1302
+ })
1303
+ current.then(resolve, resolve)
1304
+ }
1305
+
1306
+ if (tEdge < 1 || edge === origin) {
1307
+ bounce()
1308
+ } else {
1309
+ const span = edge - origin
1310
+ current = animate(position, edge, {
1311
+ duration: tEdge / 1000,
1312
+ // The decay's own curve, normalised to 0..1 over the way to the edge
1313
+ ease: p => (amplitude * (1 - Math.exp((-p * tEdge) / tau))) / span,
1314
+ })
1315
+ current.then(bounce, () => {})
1316
+ }
1317
+
1318
+ return {
1319
+ stop() {
1320
+ stopped = true
1321
+ current?.stop()
1322
+ resolve()
1323
+ },
1324
+ then: (onResolve, onReject) => done.then(onResolve, onReject),
1325
+ catch: onReject => done.catch(onReject),
1326
+ }
1327
+ }
1328
+
1165
1329
  /**
1166
1330
  * Find the nearest snap point to a given position
1167
1331
  * @param {number} targetPos - Position to find nearest snap point for
@@ -1248,6 +1412,18 @@ function horizontalLoop(app, items, config) {
1248
1412
  const motionVelocity =
1249
1413
  -velocity * config.throwVelocityMultiplier * config.snapVelocityMultiplier
1250
1414
 
1415
+ // A throw that would land past either end springs off the edge instead
1416
+ if (throwsPastEdge(currentPos, motionVelocity)) {
1417
+ snapAnimation = throwPastEdge(motionVelocity)
1418
+ snapAnimation
1419
+ .then(() => {
1420
+ snapAnimation = null
1421
+ updateIndexDisplay()
1422
+ })
1423
+ .catch(() => { snapAnimation = null })
1424
+ return
1425
+ }
1426
+
1251
1427
  // Calculate ideal inertia target (Motion will recalculate, but we need a non-zero animation)
1252
1428
  // This ensures Motion starts the inertia physics
1253
1429
  const idealTarget =
@@ -1909,6 +2085,9 @@ export default class Looper {
1909
2085
  snapVelocityMultiplier: this.opts.snapVelocityMultiplier,
1910
2086
  snapDuration: this.opts.snapDuration,
1911
2087
  snapBounce: this.opts.snapBounce,
2088
+ edgeStiffness: this.opts.edgeStiffness,
2089
+ edgeDamping: this.opts.edgeDamping,
2090
+ edgeElasticity: this.opts.edgeElasticity,
1912
2091
  minimumMovement: this.opts.minimumMovement,
1913
2092
  touchMinimumMovement: this.opts.touchMinimumMovement,
1914
2093
  },
@@ -52,6 +52,7 @@ import { set } from '../../utils/motion-helpers'
52
52
  /**
53
53
  * @typedef {Object} StickyHeaderSectionOptions
54
54
  * @property {boolean} [unPinOnResize=true] - Whether to unpin header on window resize
55
+ * @property {boolean} [headerHeightTracksPin=true] - Whether --header-height drops to 0 when the header unpins. Set false for a header that never retracts.
55
56
  * @property {Window|HTMLElement} [canvas=window] - Scrolling element
56
57
  * @property {string|null} [intersects=null] - Selector for elements to check intersection with
57
58
  * @property {Function} [beforeEnter] - Called before header enters
@@ -155,6 +156,7 @@ const DEFAULT_OPTIONS = {
155
156
 
156
157
  default: {
157
158
  unPinOnResize: true,
159
+ headerHeightTracksPin: true,
158
160
  canvas: window,
159
161
  intersects: null,
160
162
  beforeEnter: (h) => {
@@ -609,10 +611,24 @@ export default class StickyHeader {
609
611
 
610
612
  /**
611
613
  * Update the --header-height CSS variable on :root.
612
- * Set to the header's current height when pinned, 0px when unpinned.
614
+ *
615
+ * By default this is how much header is *visible* — the measured height when
616
+ * pinned, 0 when unpinned — so anything positioned under the bar follows it
617
+ * out of the way as it retracts.
618
+ *
619
+ * That is wrong for a header configured never to retract, whether through
620
+ * `preventUnpin` or by no-opping `onPin` / `onUnpin`. The bar stays put, but
621
+ * `_pinned` still flips on every change of scroll direction, so the variable
622
+ * drops to 0 and back while nothing moves. Any layout sized from it then grows
623
+ * and shrinks the document under the reader. Scroll anchoring absorbs that
624
+ * mid-page, but not at the very bottom, where the scroll offset is clamped to
625
+ * the document and the page visibly jumps instead.
626
+ *
627
+ * `headerHeightTracksPin: false` publishes the measured height throughout.
613
628
  */
614
629
  _updateHeaderHeight() {
615
- const height = this._pinned ? `${this.el.clientHeight}px` : '0px'
630
+ const tracksPin = this.opts.headerHeightTracksPin !== false
631
+ const height = tracksPin && !this._pinned ? '0px' : `${this.el.clientHeight}px`
616
632
  document.documentElement.style.setProperty('--header-height', height)
617
633
  }
618
634
 
@@ -48,8 +48,21 @@ export default class DoubleHeader {
48
48
  small(): void;
49
49
  /**
50
50
  * Update the --header-height CSS variable on :root.
51
- * Uses el height when pinned (el is the main header, auxEl is secondary).
52
- * Set to 0px when unpinned.
51
+ *
52
+ * Measured from `el`, the main header — `auxEl` is the clone. By default this
53
+ * is how much header is *visible*, the measured height when pinned and 0 when
54
+ * unpinned, so anything positioned under the bar follows it out of the way as
55
+ * the clone retracts.
56
+ *
57
+ * That is wrong for a header configured never to retract, whether through
58
+ * `preventUnpin` or by no-opping `onPin` / `onUnpin`. The bar stays put, but
59
+ * `_pinned` still flips on every change of scroll direction, so the variable
60
+ * drops to 0 and back while nothing moves. Any layout sized from it then grows
61
+ * and shrinks the document under the reader. Scroll anchoring absorbs that
62
+ * mid-page, but not at the very bottom, where the scroll offset is clamped to
63
+ * the document and the page visibly jumps instead.
64
+ *
65
+ * `headerHeightTracksPin: false` publishes the measured height throughout.
53
66
  */
54
67
  _updateHeaderHeight(): void;
55
68
  shouldUnpin(toleranceExceeded: any): any;
@@ -59,7 +59,20 @@ export default class StickyHeader {
59
59
  small(): void;
60
60
  /**
61
61
  * Update the --header-height CSS variable on :root.
62
- * Set to the header's current height when pinned, 0px when unpinned.
62
+ *
63
+ * By default this is how much header is *visible* — the measured height when
64
+ * pinned, 0 when unpinned — so anything positioned under the bar follows it
65
+ * out of the way as it retracts.
66
+ *
67
+ * That is wrong for a header configured never to retract, whether through
68
+ * `preventUnpin` or by no-opping `onPin` / `onUnpin`. The bar stays put, but
69
+ * `_pinned` still flips on every change of scroll direction, so the variable
70
+ * drops to 0 and back while nothing moves. Any layout sized from it then grows
71
+ * and shrinks the document under the reader. Scroll anchoring absorbs that
72
+ * mid-page, but not at the very bottom, where the scroll offset is clamped to
73
+ * the document and the page visibly jumps instead.
74
+ *
75
+ * `headerHeightTracksPin: false` publishes the measured height throughout.
63
76
  */
64
77
  _updateHeaderHeight(): void;
65
78
  notAltBg(): void;
@@ -143,6 +156,10 @@ export type StickyHeaderSectionOptions = {
143
156
  * - Whether to unpin header on window resize
144
157
  */
145
158
  unPinOnResize?: boolean;
159
+ /**
160
+ * - Whether --header-height drops to 0 when the header unpins. Set false for a header that never retracts.
161
+ */
162
+ headerHeightTracksPin?: boolean;
146
163
  /**
147
164
  * - Scrolling element
148
165
  */