@revenuecat/purchases-ui-js 4.14.0 → 4.14.2

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.
@@ -224,7 +224,12 @@
224
224
 
225
225
  let startTranslation = $state(0);
226
226
 
227
+ // Releasing after holding still this long is not a flick, however fast the drag was.
228
+ const FLICK_MAX_PAUSE_MS = 100;
229
+
227
230
  let lastClientX = 0;
231
+ let lastMoveTimeStamp = 0;
232
+ let velocityPxPerMs: number | null = null;
228
233
 
229
234
  function onpointerdown(event: PointerEvent) {
230
235
  const target = event.currentTarget as HTMLElement;
@@ -232,9 +237,21 @@
232
237
  startTranslation = getTranslation(slider);
233
238
  gestureStartPage = currentPage;
234
239
  lastClientX = event.clientX;
240
+ lastMoveTimeStamp = event.timeStamp;
241
+ velocityPxPerMs = null;
235
242
  }
236
243
 
237
244
  function onpointerup(event: PointerEvent) {
245
+ const stalled = event.timeStamp - lastMoveTimeStamp > FLICK_MAX_PAUSE_MS;
246
+ settleGesture(event, stalled ? 0 : (velocityPxPerMs ?? 0));
247
+ }
248
+
249
+ // Fires when the browser takes over the touch (e.g. vertical scroll).
250
+ function onpointercancel(event: PointerEvent) {
251
+ settleGesture(event, 0);
252
+ }
253
+
254
+ function settleGesture(event: PointerEvent, releaseVelocityPxPerMs: number) {
238
255
  const target = event.currentTarget as HTMLElement;
239
256
  if (target.hasPointerCapture(event.pointerId)) {
240
257
  target.releasePointerCapture(event.pointerId);
@@ -242,12 +259,10 @@
242
259
 
243
260
  const translation = getTranslation(slider);
244
261
 
245
- // Advance at most one page from where the gesture started, based on how
246
- // far the finger travelled relative to a page's width (see
247
- // getSwipeTargetPage).
248
262
  const page = getSwipeTargetPage({
249
263
  startPage: gestureStartPage ?? getPageFromTranslation(translation),
250
264
  dragDistance: translation - startTranslation,
265
+ velocityPxPerMs: releaseVelocityPxPerMs,
251
266
  pageWidth,
252
267
  pageCount: pages.length,
253
268
  loop: carouselPagingLoop,
@@ -284,7 +299,17 @@
284
299
  // for touch-derived pointer events in Safari and device-pixel scaled in
285
300
  // some Chrome configurations.
286
301
  const deltaX = event.clientX - lastClientX;
302
+ // At least 1ms: synthetic input and coarse timestamps can report 0ms.
303
+ const deltaT = Math.max(1, event.timeStamp - lastMoveTimeStamp);
287
304
  lastClientX = event.clientX;
305
+ lastMoveTimeStamp = event.timeStamp;
306
+ // Moving average, so one jittery pointermove doesn't decide the release
307
+ // speed; the first sample seeds it so short flicks aren't underestimated.
308
+ const sampleVelocity = deltaX / deltaT;
309
+ velocityPxPerMs =
310
+ velocityPxPerMs === null
311
+ ? sampleVelocity
312
+ : 0.6 * velocityPxPerMs + 0.4 * sampleVelocity;
288
313
 
289
314
  const [min, max] = carouselPagingLoop
290
315
  ? [startTranslation - carouselWidth, startTranslation + carouselWidth]
@@ -330,6 +355,7 @@
330
355
  {ontransitionend}
331
356
  {onpointerdown}
332
357
  {onpointerup}
358
+ {onpointercancel}
333
359
  {onpointermove}
334
360
  class="slider"
335
361
  style={sliderStyle}
@@ -7,35 +7,39 @@ export declare function clamp(value: number, min: number, max: number): number;
7
7
  * `Array.prototype.at` so Chrome < 92 does not throw while mounting.
8
8
  */
9
9
  export declare function getDefaultPageIndex<T>(pages: readonly T[], initialPageIndex: number): number;
10
+ /** Release speed (0.4 px/ms = 400 px/s) at or above which a swipe counts as a flick. */
11
+ export declare const FLICK_VELOCITY_PX_PER_MS = 0.4;
10
12
  /**
11
- * Advance one page when the drag passed this fraction of a page's width,
12
- * matching the iOS SDK carousel. A shorter drag snaps back to the page the
13
- * gesture started on.
14
- */
15
- export declare const PAGE_ADVANCE_THRESHOLD = 0.2;
16
- /**
17
- * Decides which page a swipe settles on when the pointer is released.
13
+ * Decides which page a swipe settles on when the pointer is released. Matches
14
+ * the native (iOS and Android) SDK carousels:
15
+ *
16
+ * - A flick (release speed >= `FLICK_VELOCITY_PX_PER_MS`) moves in the
17
+ * direction the pointer was travelling when released. From a partial drag it
18
+ * lands on the next page boundary that way, so dragging forward then flicking
19
+ * back returns to the start page.
20
+ * - A slower release settles on whichever page is nearest, i.e. the drag must
21
+ * pass `SLOW_DRAG_THRESHOLD` of a page's width to change page.
22
+ * - The result is never more than one page from `startPage`.
18
23
  *
19
- * Distance-based (not velocity-based), mirroring the iOS SDK's `handleDragEnd`:
20
- * a drag past `PAGE_ADVANCE_THRESHOLD` of a page's width advances exactly one
21
- * page in the swipe direction regardless of speed; a shorter drag stays put.
22
24
  * Non-loop carousels clamp at their first and last page; loop carousels may
23
25
  * return an index just outside `[0, pageCount)` so the caller can animate into
24
26
  * a clone before normalizing.
25
27
  *
26
- * @param startPage Page the gesture started on.
27
- * @param dragDistance Signed change in slider translation (negative = finger
28
- * moved left / next page).
29
- * @param pageWidth Width of a single page in px.
30
- * @param pageCount Total number of real pages.
31
- * @param loop Whether the carousel loops.
28
+ * @param startPage Page the gesture started on.
29
+ * @param dragDistance Signed change in slider translation in px (negative =
30
+ * pointer moved left / next page).
31
+ * @param velocityPxPerMs Signed pointer velocity at release, same sign
32
+ * convention as `dragDistance`.
33
+ * @param pageWidth Width of a single page in px.
34
+ * @param pageCount Total number of real pages.
35
+ * @param loop Whether the carousel loops.
32
36
  */
33
- export declare function getSwipeTargetPage({ startPage, dragDistance, pageWidth, pageCount, loop, advanceThreshold, }: {
37
+ export declare function getSwipeTargetPage({ startPage, dragDistance, velocityPxPerMs, pageWidth, pageCount, loop, }: {
34
38
  startPage: number;
35
39
  dragDistance: number;
40
+ velocityPxPerMs: number;
36
41
  pageWidth: number;
37
42
  pageCount: number;
38
43
  loop: boolean;
39
- advanceThreshold?: number;
40
44
  }): number;
41
45
  export declare function getTranslation(element: HTMLElement | null): number;
@@ -19,40 +19,54 @@ export function clamp(value, min, max) {
19
19
  export function getDefaultPageIndex(pages, initialPageIndex) {
20
20
  return pages[initialPageIndex] !== undefined ? initialPageIndex : 0;
21
21
  }
22
+ /** Fraction of a page's width a slow drag must pass to change page. */
23
+ const SLOW_DRAG_THRESHOLD = 0.5;
24
+ /** Release speed (0.4 px/ms = 400 px/s) at or above which a swipe counts as a flick. */
25
+ export const FLICK_VELOCITY_PX_PER_MS = 0.4;
22
26
  /**
23
- * Advance one page when the drag passed this fraction of a page's width,
24
- * matching the iOS SDK carousel. A shorter drag snaps back to the page the
25
- * gesture started on.
26
- */
27
- export const PAGE_ADVANCE_THRESHOLD = 0.2;
28
- /**
29
- * Decides which page a swipe settles on when the pointer is released.
27
+ * Decides which page a swipe settles on when the pointer is released. Matches
28
+ * the native (iOS and Android) SDK carousels:
29
+ *
30
+ * - A flick (release speed >= `FLICK_VELOCITY_PX_PER_MS`) moves in the
31
+ * direction the pointer was travelling when released. From a partial drag it
32
+ * lands on the next page boundary that way, so dragging forward then flicking
33
+ * back returns to the start page.
34
+ * - A slower release settles on whichever page is nearest, i.e. the drag must
35
+ * pass `SLOW_DRAG_THRESHOLD` of a page's width to change page.
36
+ * - The result is never more than one page from `startPage`.
30
37
  *
31
- * Distance-based (not velocity-based), mirroring the iOS SDK's `handleDragEnd`:
32
- * a drag past `PAGE_ADVANCE_THRESHOLD` of a page's width advances exactly one
33
- * page in the swipe direction regardless of speed; a shorter drag stays put.
34
38
  * Non-loop carousels clamp at their first and last page; loop carousels may
35
39
  * return an index just outside `[0, pageCount)` so the caller can animate into
36
40
  * a clone before normalizing.
37
41
  *
38
- * @param startPage Page the gesture started on.
39
- * @param dragDistance Signed change in slider translation (negative = finger
40
- * moved left / next page).
41
- * @param pageWidth Width of a single page in px.
42
- * @param pageCount Total number of real pages.
43
- * @param loop Whether the carousel loops.
42
+ * @param startPage Page the gesture started on.
43
+ * @param dragDistance Signed change in slider translation in px (negative =
44
+ * pointer moved left / next page).
45
+ * @param velocityPxPerMs Signed pointer velocity at release, same sign
46
+ * convention as `dragDistance`.
47
+ * @param pageWidth Width of a single page in px.
48
+ * @param pageCount Total number of real pages.
49
+ * @param loop Whether the carousel loops.
44
50
  */
45
- export function getSwipeTargetPage({ startPage, dragDistance, pageWidth, pageCount, loop, advanceThreshold = PAGE_ADVANCE_THRESHOLD, }) {
46
- const threshold = pageWidth * advanceThreshold;
47
- let page = startPage;
48
- if (dragDistance < -threshold) {
49
- // Finger moved left, content shifts left: next page.
50
- page += 1;
51
+ export function getSwipeTargetPage({ startPage, dragDistance, velocityPxPerMs, pageWidth, pageCount, loop, }) {
52
+ // Pages travelled from startPage; positive = towards the next page.
53
+ const offset = -dragDistance / pageWidth;
54
+ // A flick lands on the next whole page past the current offset in its
55
+ // direction; from a page-aligned start that is the adjacent page.
56
+ let pageDelta;
57
+ if (velocityPxPerMs <= -FLICK_VELOCITY_PX_PER_MS) {
58
+ pageDelta = Math.floor(offset) + 1;
59
+ }
60
+ else if (velocityPxPerMs >= FLICK_VELOCITY_PX_PER_MS) {
61
+ pageDelta = Math.ceil(offset) - 1;
62
+ }
63
+ else if (Math.abs(offset) > SLOW_DRAG_THRESHOLD) {
64
+ pageDelta = Math.sign(offset);
51
65
  }
52
- else if (dragDistance > threshold) {
53
- // Finger moved right: previous page.
54
- page -= 1;
66
+ else {
67
+ pageDelta = 0;
55
68
  }
69
+ const page = startPage + clamp(pageDelta, -1, 1);
56
70
  return loop ? page : clamp(page, 0, pageCount - 1);
57
71
  }
58
72
  export function getTranslation(element) {
@@ -478,6 +478,9 @@
478
478
  let headerHeight = $state(0);
479
479
  let footerHeight = $state(0);
480
480
 
481
+ // A horizontal root's direction is row, so the spacer would incorrectly sit beside the content
482
+ const spacerFitsRoot = stack.dimension.type !== "horizontal";
483
+
481
484
  const contentColumnStyle = $derived(
482
485
  maxContentWidth
483
486
  ? `max-width: ${maxContentWidth}; margin-inline: auto;`
@@ -501,6 +504,12 @@
501
504
  <div class="paywall-content" style={contentColumnStyle}>
502
505
  <Stack
503
506
  {...stack}
507
+ padding={sticky_footer && !spacerFitsRoot
508
+ ? {
509
+ ...stack.padding,
510
+ bottom: (stack.padding?.bottom ?? 0) + footerHeight,
511
+ }
512
+ : stack.padding}
504
513
  class="paywall-content-scroll"
505
514
  style={mapPaywallContentStackStyle(
506
515
  stack.size.height,
@@ -508,9 +517,9 @@
508
517
  headerHeight,
509
518
  )}
510
519
  >
511
- {#if sticky_footer}
512
- <!-- Adds a real flex item rather than bottom padding to clear
513
- a footer because iOS Safari ignores the padding if the content
520
+ {#if sticky_footer && spacerFitsRoot}
521
+ <!-- Adds a real flex item rather than bottom padding to clear
522
+ a footer because iOS Safari ignores the padding if the content
514
523
  still fits on screen leaving content stuck behind the footer. -->
515
524
  <div
516
525
  class="footer-scroll-spacer"
@@ -64,7 +64,8 @@ export function mapStackSize(size, parentDimension) {
64
64
  }
65
65
  export function mapDimension(dimension) {
66
66
  if (dimension.type === "zlayer") {
67
- return {};
67
+ // The default direction is row, so we need to set it to column to keep the flow vertical.
68
+ return { "flex-direction": "column" };
68
69
  }
69
70
  const direction = dimension.type === "vertical" ? "column" : "row";
70
71
  const justify = DISTRIBUTION_MAP[dimension.distribution] || "center";
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@revenuecat/purchases-ui-js",
3
3
  "description": "Web components for Paywalls. Powered by RevenueCat",
4
4
  "private": false,
5
- "version": "4.14.0",
5
+ "version": "4.14.2",
6
6
  "author": {
7
7
  "name": "RevenueCat, Inc."
8
8
  },