@revenuecat/purchases-ui-js 4.15.0 → 4.17.0
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/dist/components/package/Package.svelte +82 -10
- package/dist/components/paywall/Paywall.stories.svelte +335 -1
- package/dist/components/paywall/Paywall.svelte +90 -4
- package/dist/components/paywall/Sheet.svelte +31 -1
- package/dist/components/paywall/fixtures/stack-scroll-paywall.d.ts +40 -0
- package/dist/components/paywall/fixtures/stack-scroll-paywall.js +280 -0
- package/dist/components/paywall/fixtures/window-rule-packages-paywall.d.ts +14 -0
- package/dist/components/paywall/fixtures/window-rule-packages-paywall.js +89 -0
- package/dist/components/paywall/fixtures/window-size-paywall.d.ts +2 -0
- package/dist/components/paywall/fixtures/window-size-paywall.js +48 -0
- package/dist/components/stack/Stack.svelte +87 -9
- package/dist/components/stack/Stack.svelte.d.ts +2 -0
- package/dist/components/stack/stack-scroll.d.ts +59 -0
- package/dist/components/stack/stack-scroll.js +162 -0
- package/dist/components/tabs/Tabs.svelte +11 -0
- package/dist/stores/condition-context.svelte.js +3 -0
- package/dist/stores/default-selection.d.ts +17 -0
- package/dist/stores/default-selection.js +21 -0
- package/dist/stores/rendered-packages.d.ts +10 -0
- package/dist/stores/rendered-packages.js +17 -0
- package/dist/stores/window-size.d.ts +10 -0
- package/dist/stores/window-size.js +10 -0
- package/dist/types/components/package.d.ts +1 -0
- package/dist/types/components/stack.d.ts +6 -0
- package/dist/types/overrides.d.ts +15 -1
- package/dist/types/overrides.js +3 -0
- package/dist/utils/cookie-consent.d.ts +55 -0
- package/dist/utils/cookie-consent.js +36 -0
- package/dist/utils/package-selection.d.ts +26 -0
- package/dist/utils/package-selection.js +47 -0
- package/dist/utils/style-utils.d.ts +12 -0
- package/dist/utils/style-utils.js +59 -0
- package/package.json +6 -2
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
setVariablesContext,
|
|
9
9
|
} from "../../stores/variables";
|
|
10
10
|
import type { PackageProps } from "../../types/components/package";
|
|
11
|
-
import { untrack } from "svelte";
|
|
11
|
+
import { onDestroy, untrack } from "svelte";
|
|
12
12
|
import { writable } from "svelte/store";
|
|
13
13
|
import {
|
|
14
14
|
getPackageInfoContext,
|
|
@@ -20,9 +20,29 @@
|
|
|
20
20
|
getSelectedPackageScopeContext,
|
|
21
21
|
} from "../../stores/relativeDiscount";
|
|
22
22
|
import { withScopedRelativeDiscount } from "../../utils/relative-discount";
|
|
23
|
+
import { useConditionContext } from "../../stores/condition-context.svelte";
|
|
24
|
+
import {
|
|
25
|
+
getOptionalRenderedPackagesContext,
|
|
26
|
+
getPackageSheetContext,
|
|
27
|
+
} from "../../stores/rendered-packages";
|
|
28
|
+
import { getDefaultSelectionContext } from "../../stores/default-selection";
|
|
29
|
+
import {
|
|
30
|
+
resolveOverrideProperties,
|
|
31
|
+
type ConditionContext,
|
|
32
|
+
} from "../../utils/style-utils";
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
id,
|
|
36
|
+
visible,
|
|
37
|
+
overrides,
|
|
38
|
+
stack,
|
|
39
|
+
package_id,
|
|
40
|
+
is_selected_by_default,
|
|
41
|
+
name,
|
|
42
|
+
}: PackageProps = $props();
|
|
23
43
|
|
|
24
|
-
|
|
25
|
-
|
|
44
|
+
// Without state, as the SDKs evaluate a package's own rules.
|
|
45
|
+
const buildConditionContext = useConditionContext();
|
|
26
46
|
|
|
27
47
|
// Expose the current package_id via a store so descendants stay in sync
|
|
28
48
|
// when this component is reused across tab switches
|
|
@@ -41,16 +61,19 @@
|
|
|
41
61
|
infoPerPackage,
|
|
42
62
|
} = getPaywallContext();
|
|
43
63
|
|
|
44
|
-
// When a package with is_selected_by_default appears,
|
|
45
|
-
// This handles tab switches where a new default package
|
|
46
|
-
// selectedPackageId is deliberately not tracked so that manually
|
|
47
|
-
// selecting another package does not re-trigger this effect and
|
|
48
|
-
//
|
|
64
|
+
// When a package with is_selected_by_default appears with its paywall, tab
|
|
65
|
+
// or sheet, select it. This handles tab switches where a new default package
|
|
66
|
+
// is shown. selectedPackageId is deliberately not tracked so that manually
|
|
67
|
+
// selecting another package does not re-trigger this effect and snap
|
|
68
|
+
// selection back to the default.
|
|
69
|
+
const isDefaultSelectionOpen = getDefaultSelectionContext();
|
|
49
70
|
$effect(() => {
|
|
50
71
|
if (is_selected_by_default) {
|
|
51
72
|
const newPackageId = package_id;
|
|
52
73
|
untrack(() => {
|
|
53
|
-
|
|
74
|
+
if (isDefaultSelectionOpen()) {
|
|
75
|
+
$selectedPackageId = newPackageId;
|
|
76
|
+
}
|
|
54
77
|
});
|
|
55
78
|
}
|
|
56
79
|
});
|
|
@@ -127,6 +150,55 @@
|
|
|
127
150
|
packageInfoStore.set(computedPackageInfo);
|
|
128
151
|
});
|
|
129
152
|
setPackageInfoContext(packageInfoStore);
|
|
153
|
+
|
|
154
|
+
// buildConditionContext() reads the contexts above this package, so pass its
|
|
155
|
+
// own selected state, package info and variables.
|
|
156
|
+
const visibleWith = (
|
|
157
|
+
context: Partial<Pick<ConditionContext, "selected" | "selectedPackageId">>,
|
|
158
|
+
) => {
|
|
159
|
+
const resolved = resolveOverrideProperties(
|
|
160
|
+
{
|
|
161
|
+
...buildConditionContext(),
|
|
162
|
+
packageInfo: computedPackageInfo,
|
|
163
|
+
...(computedVariables ? { variables: computedVariables } : {}),
|
|
164
|
+
...context,
|
|
165
|
+
},
|
|
166
|
+
overrides,
|
|
167
|
+
);
|
|
168
|
+
return (resolved.visible ?? visible) !== false;
|
|
169
|
+
};
|
|
170
|
+
const isVisible = $derived(visibleWith({ selected: isSelected }));
|
|
171
|
+
// Only a package that shows both now, unselected, and once it's selected can
|
|
172
|
+
// take the selection; one hidden once selected would just hide again.
|
|
173
|
+
const isSelectable = $derived(
|
|
174
|
+
visibleWith({ selected: false }) &&
|
|
175
|
+
visibleWith({ selected: true, selectedPackageId: package_id }),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const renderedPackages = getOptionalRenderedPackagesContext();
|
|
179
|
+
const sheetId = getPackageSheetContext();
|
|
180
|
+
if (renderedPackages) {
|
|
181
|
+
const registration = Symbol("package");
|
|
182
|
+
$effect(() => {
|
|
183
|
+
// Getters, so the paywall reads the current visibility even before this
|
|
184
|
+
// effect re-runs.
|
|
185
|
+
renderedPackages.set(registration, {
|
|
186
|
+
componentId: id,
|
|
187
|
+
packageId: package_id,
|
|
188
|
+
isSelectedByDefault: is_selected_by_default,
|
|
189
|
+
sheetId,
|
|
190
|
+
get visible() {
|
|
191
|
+
return isVisible;
|
|
192
|
+
},
|
|
193
|
+
get selectable() {
|
|
194
|
+
return isSelectable;
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
onDestroy(() => renderedPackages.delete(registration));
|
|
199
|
+
}
|
|
130
200
|
</script>
|
|
131
201
|
|
|
132
|
-
|
|
202
|
+
{#if isVisible}
|
|
203
|
+
<Stack {...stack} onclick={onPackageClick} />
|
|
204
|
+
{/if}
|
|
@@ -23,13 +23,29 @@
|
|
|
23
23
|
zStackTemplate,
|
|
24
24
|
} from "../../stories/fixtures";
|
|
25
25
|
import { waitForAnimations } from "storybook/internal/preview-api";
|
|
26
|
-
import { expect, userEvent, within } from "storybook/test";
|
|
26
|
+
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
|
27
27
|
import { viewportDecorator } from "../../stories/viewport-decorator";
|
|
28
28
|
import { BACKGROUND_PAYWALL } from "./fixtures/background-paywall";
|
|
29
29
|
import { FIXED_HEIGHT_STACK_PAYWALL } from "./fixtures/fixed-height-stack-paywall";
|
|
30
|
+
import {
|
|
31
|
+
ALIGNED_SCROLL_PAYWALL,
|
|
32
|
+
CENTERED_ROW_SCROLL_PAYWALL,
|
|
33
|
+
DECORATED_SCROLL_PAYWALL,
|
|
34
|
+
fitAroundFitScrollPaywall,
|
|
35
|
+
HORIZONTAL_SCROLL_PAYWALL,
|
|
36
|
+
ROOT_NO_SCROLL_PAYWALL,
|
|
37
|
+
SCROLLING_FILL_IN_FIT_SHEET_PAYWALL,
|
|
38
|
+
SCROLLING_FILL_SHEET_PAYWALL,
|
|
39
|
+
SCROLLING_FIT_SHEET_PAYWALL,
|
|
40
|
+
SIDE_BY_SIDE_SCROLL_PAYWALL,
|
|
41
|
+
STACKED_FIT_SCROLL_PAYWALL,
|
|
42
|
+
WINDOW_RULE_COLUMNS_PAYWALL,
|
|
43
|
+
} from "./fixtures/stack-scroll-paywall";
|
|
30
44
|
import { FILL_HEIGHT_WEBVIEW_PAYWALL } from "./fixtures/fill-height-webview-paywall";
|
|
45
|
+
import { WINDOW_RULE_PACKAGES_PAYWALL } from "./fixtures/window-rule-packages-paywall";
|
|
31
46
|
import { createHeroMediaPaywall } from "./fixtures/hero-media-paywall";
|
|
32
47
|
import { OVERRIDE_PAYWALL } from "./fixtures/override-paywall";
|
|
48
|
+
import { WINDOW_SIZE_PAYWALL } from "./fixtures/window-size-paywall";
|
|
33
49
|
import { SHEET_PAYWALL } from "./fixtures/sheet-paywall";
|
|
34
50
|
import { SHEET_PAYWALL_VIDEO_STACKING } from "./fixtures/sheet-video-stacking-paywall";
|
|
35
51
|
import { GRADIENT_SHEET_PAYWALL } from "./fixtures/gradient-sheet-paywall";
|
|
@@ -56,6 +72,30 @@
|
|
|
56
72
|
paywallWithHeaderAndFooter,
|
|
57
73
|
} from "./fixtures/transparent-footer-paywall";
|
|
58
74
|
|
|
75
|
+
// Boxes inside `root` that are scrolling containers with content to scroll.
|
|
76
|
+
const scrollers = (root: Element) =>
|
|
77
|
+
[...root.querySelectorAll<HTMLElement>("*")].filter((el) => {
|
|
78
|
+
const style = getComputedStyle(el);
|
|
79
|
+
const scrolls = (overflow: string) =>
|
|
80
|
+
["auto", "scroll"].includes(overflow);
|
|
81
|
+
return (
|
|
82
|
+
(scrolls(style.overflowY) && el.scrollHeight > el.clientHeight + 1) ||
|
|
83
|
+
(scrolls(style.overflowX) && el.scrollWidth > el.clientWidth + 1)
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// The fixed green box below a scrolling column is fully in view.
|
|
88
|
+
const expectOfferInView = (root: Element) => {
|
|
89
|
+
const offer = [...root.querySelectorAll(".stack")].find(
|
|
90
|
+
(el) => getComputedStyle(el).backgroundColor === "rgb(17, 212, 131)",
|
|
91
|
+
)!;
|
|
92
|
+
const paywall = root.querySelector(".paywall-content-scroll")!;
|
|
93
|
+
expect(offer.getBoundingClientRect().height).toBe(120);
|
|
94
|
+
expect(offer.getBoundingClientRect().bottom).toBeLessThanOrEqual(
|
|
95
|
+
paywall.getBoundingClientRect().bottom,
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
59
99
|
const { Story } = defineMeta({
|
|
60
100
|
title: "Example/Paywall",
|
|
61
101
|
component: Paywall,
|
|
@@ -105,6 +145,273 @@
|
|
|
105
145
|
}}
|
|
106
146
|
/>
|
|
107
147
|
|
|
148
|
+
<!--
|
|
149
|
+
Scroll stories render in a frame bounded to the viewport, so scrolling stacks
|
|
150
|
+
have a size to stop at. The shared frame grows with its content.
|
|
151
|
+
-->
|
|
152
|
+
{#snippet boundedFrame(props: ComponentProps<typeof Paywall>)}
|
|
153
|
+
<div class="paywall-story-frame bounded">
|
|
154
|
+
<Paywall {...props} />
|
|
155
|
+
</div>
|
|
156
|
+
{/snippet}
|
|
157
|
+
|
|
158
|
+
<Story
|
|
159
|
+
name="Scrolling Fit stack above a fixed box"
|
|
160
|
+
decorators={[viewportDecorator(375, 640, 0)]}
|
|
161
|
+
play={async ({ canvasElement }) => {
|
|
162
|
+
await waitFor(() => {
|
|
163
|
+
expect(scrollers(canvasElement)).toHaveLength(1);
|
|
164
|
+
expectOfferInView(canvasElement);
|
|
165
|
+
});
|
|
166
|
+
}}
|
|
167
|
+
args={{ paywallData: STACKED_FIT_SCROLL_PAYWALL }}
|
|
168
|
+
template={boundedFrame}
|
|
169
|
+
/>
|
|
170
|
+
|
|
171
|
+
<Story
|
|
172
|
+
name="Scrolling Fit stacks side by side"
|
|
173
|
+
decorators={[viewportDecorator(800, 600, 0)]}
|
|
174
|
+
play={async ({ canvasElement }) => {
|
|
175
|
+
await waitFor(() => expect(scrollers(canvasElement)).toHaveLength(2));
|
|
176
|
+
}}
|
|
177
|
+
args={{ paywallData: SIDE_BY_SIDE_SCROLL_PAYWALL }}
|
|
178
|
+
template={boundedFrame}
|
|
179
|
+
/>
|
|
180
|
+
|
|
181
|
+
<Story
|
|
182
|
+
name="Scrolling Fit stacks in a centered row"
|
|
183
|
+
decorators={[viewportDecorator(800, 600, 0)]}
|
|
184
|
+
play={async ({ canvasElement }) => {
|
|
185
|
+
const box = (id: string) =>
|
|
186
|
+
[...canvasElement.querySelectorAll(".stack")].find(
|
|
187
|
+
(el) =>
|
|
188
|
+
getComputedStyle(el).backgroundColor === id &&
|
|
189
|
+
el.closest(".rc-stack-scroll"),
|
|
190
|
+
)!;
|
|
191
|
+
await waitFor(() => {
|
|
192
|
+
// Only the tall column scrolls.
|
|
193
|
+
expect(scrollers(canvasElement)).toHaveLength(1);
|
|
194
|
+
const paywall = canvasElement
|
|
195
|
+
.querySelector(".paywall-content-scroll")!
|
|
196
|
+
.getBoundingClientRect();
|
|
197
|
+
const short = box("rgb(17, 212, 131)")
|
|
198
|
+
.closest(".rc-stack-scroll")!
|
|
199
|
+
.parentElement!.getBoundingClientRect();
|
|
200
|
+
// The short column stays centered in the row.
|
|
201
|
+
const paywallMiddle = paywall.top + paywall.height / 2;
|
|
202
|
+
expect(
|
|
203
|
+
Math.abs(short.top + short.height / 2 - paywallMiddle),
|
|
204
|
+
).toBeLessThan(1);
|
|
205
|
+
});
|
|
206
|
+
}}
|
|
207
|
+
args={{ paywallData: CENTERED_ROW_SCROLL_PAYWALL }}
|
|
208
|
+
template={boundedFrame}
|
|
209
|
+
/>
|
|
210
|
+
|
|
211
|
+
<Story
|
|
212
|
+
name="Horizontal scrolling stack"
|
|
213
|
+
decorators={[viewportDecorator(375, 640, 0)]}
|
|
214
|
+
play={async ({ canvasElement }) => {
|
|
215
|
+
await waitFor(() => {
|
|
216
|
+
const [row, ...others] = scrollers(canvasElement);
|
|
217
|
+
expect(others).toHaveLength(0);
|
|
218
|
+
expect(row?.classList.contains("rc-stack-scroll")).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
}}
|
|
221
|
+
args={{ paywallData: HORIZONTAL_SCROLL_PAYWALL }}
|
|
222
|
+
template={boundedFrame}
|
|
223
|
+
/>
|
|
224
|
+
|
|
225
|
+
<Story
|
|
226
|
+
name="Root scrolling off"
|
|
227
|
+
decorators={[viewportDecorator(375, 640, 0)]}
|
|
228
|
+
play={async ({ canvasElement }) => {
|
|
229
|
+
await waitFor(() => expect(scrollers(canvasElement)).toHaveLength(0));
|
|
230
|
+
}}
|
|
231
|
+
args={{ paywallData: ROOT_NO_SCROLL_PAYWALL }}
|
|
232
|
+
template={boundedFrame}
|
|
233
|
+
/>
|
|
234
|
+
|
|
235
|
+
<Story
|
|
236
|
+
name="Window size rule, narrow"
|
|
237
|
+
decorators={[viewportDecorator(375, 640, 0)]}
|
|
238
|
+
play={async ({ canvasElement }) => {
|
|
239
|
+
await waitFor(() =>
|
|
240
|
+
expect(
|
|
241
|
+
scrollers(canvasElement).map((el) =>
|
|
242
|
+
el.classList.contains("paywall-content-scroll"),
|
|
243
|
+
),
|
|
244
|
+
).toEqual([true]),
|
|
245
|
+
);
|
|
246
|
+
}}
|
|
247
|
+
args={{ paywallData: WINDOW_RULE_COLUMNS_PAYWALL }}
|
|
248
|
+
template={boundedFrame}
|
|
249
|
+
/>
|
|
250
|
+
|
|
251
|
+
<Story
|
|
252
|
+
name="Window size rule, wide"
|
|
253
|
+
decorators={[viewportDecorator(800, 600, 0)]}
|
|
254
|
+
play={async ({ canvasElement }) => {
|
|
255
|
+
await waitFor(() => expect(scrollers(canvasElement)).toHaveLength(2));
|
|
256
|
+
}}
|
|
257
|
+
args={{ paywallData: WINDOW_RULE_COLUMNS_PAYWALL }}
|
|
258
|
+
template={boundedFrame}
|
|
259
|
+
/>
|
|
260
|
+
|
|
261
|
+
<Story
|
|
262
|
+
name="Scrolling stacks aligned to the center and end"
|
|
263
|
+
decorators={[viewportDecorator(375, 640, 0)]}
|
|
264
|
+
play={async ({ canvasElement }) => {
|
|
265
|
+
await waitFor(() => expect(scrollers(canvasElement)).toHaveLength(2));
|
|
266
|
+
for (const viewport of scrollers(canvasElement)) {
|
|
267
|
+
const rows = viewport.firstElementChild!;
|
|
268
|
+
viewport.scrollTop = 0;
|
|
269
|
+
expect(rows.firstElementChild!.getBoundingClientRect().top).toBe(
|
|
270
|
+
viewport.getBoundingClientRect().top,
|
|
271
|
+
);
|
|
272
|
+
viewport.scrollTop = viewport.scrollHeight;
|
|
273
|
+
expect(rows.lastElementChild!.getBoundingClientRect().bottom).toBe(
|
|
274
|
+
viewport.getBoundingClientRect().bottom,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}}
|
|
278
|
+
args={{ paywallData: ALIGNED_SCROLL_PAYWALL }}
|
|
279
|
+
template={boundedFrame}
|
|
280
|
+
/>
|
|
281
|
+
|
|
282
|
+
<Story
|
|
283
|
+
name="Scrolling stacks keep their border, badge and video"
|
|
284
|
+
decorators={[viewportDecorator(375, 640, 0)]}
|
|
285
|
+
play={async ({ canvasElement }) => {
|
|
286
|
+
await waitFor(() => expect(scrollers(canvasElement)).toHaveLength(2));
|
|
287
|
+
const [bordered, video] = scrollers(canvasElement);
|
|
288
|
+
const stack = bordered!.parentElement!;
|
|
289
|
+
expect(getComputedStyle(stack).borderTopWidth).toBe("10px");
|
|
290
|
+
expect(getComputedStyle(stack).overflowY).toBe("visible");
|
|
291
|
+
const badge = [...stack.children].find((el) => el !== bordered)!;
|
|
292
|
+
const badgeTop = badge.getBoundingClientRect().top;
|
|
293
|
+
bordered!.scrollTop = 150;
|
|
294
|
+
expect(badge.getBoundingClientRect().top).toBe(badgeTop);
|
|
295
|
+
video!.scrollTop = 150;
|
|
296
|
+
expect(video!.scrollTop).toBeGreaterThan(0);
|
|
297
|
+
}}
|
|
298
|
+
args={{ paywallData: DECORATED_SCROLL_PAYWALL }}
|
|
299
|
+
template={boundedFrame}
|
|
300
|
+
/>
|
|
301
|
+
|
|
302
|
+
<Story
|
|
303
|
+
name="Fit stack around a scrolling Fit stack"
|
|
304
|
+
decorators={[viewportDecorator(375, 640, 0)]}
|
|
305
|
+
play={async ({ canvasElement }) => {
|
|
306
|
+
await waitFor(() => {
|
|
307
|
+
expect(scrollers(canvasElement)).toHaveLength(1);
|
|
308
|
+
expectOfferInView(canvasElement);
|
|
309
|
+
});
|
|
310
|
+
}}
|
|
311
|
+
args={{ paywallData: fitAroundFitScrollPaywall(false) }}
|
|
312
|
+
template={boundedFrame}
|
|
313
|
+
/>
|
|
314
|
+
|
|
315
|
+
<Story
|
|
316
|
+
name="Fit stack around a scrolling package"
|
|
317
|
+
decorators={[viewportDecorator(375, 640, 0)]}
|
|
318
|
+
play={async ({ canvasElement }) => {
|
|
319
|
+
await waitFor(() => {
|
|
320
|
+
expect(scrollers(canvasElement)).toHaveLength(1);
|
|
321
|
+
expectOfferInView(canvasElement);
|
|
322
|
+
});
|
|
323
|
+
}}
|
|
324
|
+
args={{ paywallData: fitAroundFitScrollPaywall(true) }}
|
|
325
|
+
template={boundedFrame}
|
|
326
|
+
/>
|
|
327
|
+
|
|
328
|
+
<Story
|
|
329
|
+
name="Window size rule hides a package, narrow"
|
|
330
|
+
decorators={[viewportDecorator(375, 640, 0)]}
|
|
331
|
+
play={async ({ args, canvasElement }) => {
|
|
332
|
+
const canvas = within(canvasElement);
|
|
333
|
+
await canvas.findByText(/^Annual/);
|
|
334
|
+
await userEvent.click(canvas.getByText("Purchase"));
|
|
335
|
+
await waitFor(() =>
|
|
336
|
+
expect(args.onPurchaseClicked).toHaveBeenLastCalledWith("$rc_annual", ""),
|
|
337
|
+
);
|
|
338
|
+
}}
|
|
339
|
+
args={{ paywallData: WINDOW_RULE_PACKAGES_PAYWALL, onPurchaseClicked: fn() }}
|
|
340
|
+
template={boundedFrame}
|
|
341
|
+
/>
|
|
342
|
+
|
|
343
|
+
<Story
|
|
344
|
+
name="Window size rule hides a package, wide"
|
|
345
|
+
decorators={[viewportDecorator(800, 600, 0)]}
|
|
346
|
+
play={async ({ args, canvasElement }) => {
|
|
347
|
+
const canvas = within(canvasElement);
|
|
348
|
+
await canvas.findByText(/^Monthly/);
|
|
349
|
+
expect(canvas.queryByText(/^Annual/)).toBeNull();
|
|
350
|
+
await userEvent.click(canvas.getByText("Purchase"));
|
|
351
|
+
await waitFor(() =>
|
|
352
|
+
expect(args.onPurchaseClicked).toHaveBeenLastCalledWith(
|
|
353
|
+
"$rc_monthly",
|
|
354
|
+
"",
|
|
355
|
+
),
|
|
356
|
+
);
|
|
357
|
+
}}
|
|
358
|
+
args={{ paywallData: WINDOW_RULE_PACKAGES_PAYWALL, onPurchaseClicked: fn() }}
|
|
359
|
+
template={boundedFrame}
|
|
360
|
+
/>
|
|
361
|
+
|
|
362
|
+
<Story
|
|
363
|
+
name="Scrolling Fit sheet"
|
|
364
|
+
decorators={[viewportDecorator(375, 640, 0)]}
|
|
365
|
+
play={async ({ canvasElement }) => {
|
|
366
|
+
await userEvent.click(await within(canvasElement).findByText("Open sheet"));
|
|
367
|
+
await waitFor(() => {
|
|
368
|
+
const sheet = canvasElement.querySelector(".sheet")!;
|
|
369
|
+
const [contents, ...others] = scrollers(sheet.parentElement!);
|
|
370
|
+
expect(others).toHaveLength(0);
|
|
371
|
+
expect(contents).not.toBe(sheet);
|
|
372
|
+
expect(sheet.contains(contents!)).toBe(true);
|
|
373
|
+
expect(contents!.clientHeight).toBe(sheet.parentElement!.clientHeight);
|
|
374
|
+
});
|
|
375
|
+
}}
|
|
376
|
+
args={{ paywallData: SCROLLING_FIT_SHEET_PAYWALL }}
|
|
377
|
+
template={boundedFrame}
|
|
378
|
+
/>
|
|
379
|
+
|
|
380
|
+
<Story
|
|
381
|
+
name="Scrolling Fill contents in a Fit sheet"
|
|
382
|
+
decorators={[viewportDecorator(375, 640, 0)]}
|
|
383
|
+
play={async ({ canvasElement }) => {
|
|
384
|
+
await userEvent.click(await within(canvasElement).findByText("Open sheet"));
|
|
385
|
+
await waitFor(() => {
|
|
386
|
+
const sheet = canvasElement.querySelector(".sheet")!;
|
|
387
|
+
// The sheet takes the screen's height and its contents scroll inside.
|
|
388
|
+
expect(sheet.clientHeight).toBe(sheet.parentElement!.clientHeight);
|
|
389
|
+
expect(scrollers(sheet)).toHaveLength(1);
|
|
390
|
+
});
|
|
391
|
+
}}
|
|
392
|
+
args={{ paywallData: SCROLLING_FILL_IN_FIT_SHEET_PAYWALL }}
|
|
393
|
+
template={boundedFrame}
|
|
394
|
+
/>
|
|
395
|
+
|
|
396
|
+
<Story
|
|
397
|
+
name="Scrolling Fill sheet with margins"
|
|
398
|
+
decorators={[viewportDecorator(375, 640, 0)]}
|
|
399
|
+
play={async ({ canvasElement }) => {
|
|
400
|
+
await userEvent.click(await within(canvasElement).findByText("Open sheet"));
|
|
401
|
+
await waitFor(() => {
|
|
402
|
+
const sheet = canvasElement.querySelector(".sheet")!;
|
|
403
|
+
expect(scrollers(sheet)).toHaveLength(1);
|
|
404
|
+
// The contents stop 40px (their bottom margin) above the sheet's bottom.
|
|
405
|
+
const contents = sheet.querySelector(".stack")!;
|
|
406
|
+
expect(contents.getBoundingClientRect().bottom + 40).toBeLessThanOrEqual(
|
|
407
|
+
sheet.getBoundingClientRect().bottom + 0.5,
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
}}
|
|
411
|
+
args={{ paywallData: SCROLLING_FILL_SHEET_PAYWALL }}
|
|
412
|
+
template={boundedFrame}
|
|
413
|
+
/>
|
|
414
|
+
|
|
108
415
|
<!--
|
|
109
416
|
Regression for FUN-2262: fill-height Stack + fill-height WebView must fill
|
|
110
417
|
the space between the fixed header/footer bars. Collapse shows as the bars
|
|
@@ -125,6 +432,29 @@
|
|
|
125
432
|
}}
|
|
126
433
|
/>
|
|
127
434
|
|
|
435
|
+
<!-- Resize the canvas across 600px wide, or past square, to see the rules switch. -->
|
|
436
|
+
<Story
|
|
437
|
+
name="Window size rules, narrow"
|
|
438
|
+
decorators={[viewportDecorator(375, 640, 0)]}
|
|
439
|
+
play={async ({ canvasElement }) => {
|
|
440
|
+
const canvas = within(canvasElement);
|
|
441
|
+
await expect(await canvas.findByText("Width under 600px")).toBeVisible();
|
|
442
|
+
await expect(canvas.getByText("Portrait or square")).toBeVisible();
|
|
443
|
+
}}
|
|
444
|
+
args={{ paywallData: WINDOW_SIZE_PAYWALL }}
|
|
445
|
+
/>
|
|
446
|
+
|
|
447
|
+
<Story
|
|
448
|
+
name="Window size rules, wide"
|
|
449
|
+
decorators={[viewportDecorator(800, 600, 0)]}
|
|
450
|
+
play={async ({ canvasElement }) => {
|
|
451
|
+
const canvas = within(canvasElement);
|
|
452
|
+
await expect(await canvas.findByText("Width 600px or more")).toBeVisible();
|
|
453
|
+
await expect(canvas.getByText("Landscape")).toBeVisible();
|
|
454
|
+
}}
|
|
455
|
+
args={{ paywallData: WINDOW_SIZE_PAYWALL }}
|
|
456
|
+
/>
|
|
457
|
+
|
|
128
458
|
<Story
|
|
129
459
|
name="State-driven - tab state"
|
|
130
460
|
decorators={[viewportDecorator(375, 640, 0)]}
|
|
@@ -817,6 +1147,10 @@
|
|
|
817
1147
|
/>
|
|
818
1148
|
|
|
819
1149
|
<style>
|
|
1150
|
+
.paywall-story-frame.bounded {
|
|
1151
|
+
min-height: 0;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
820
1154
|
.paywall-story-frame {
|
|
821
1155
|
flex: 1 1 auto;
|
|
822
1156
|
display: flex;
|
|
@@ -50,10 +50,22 @@
|
|
|
50
50
|
} from "../../stores/relativeDiscount";
|
|
51
51
|
import {
|
|
52
52
|
collectPackageIdsInDisplayOrder,
|
|
53
|
+
collectPackagePlacements,
|
|
53
54
|
findSelectedPackageId,
|
|
54
55
|
} from "../../utils/style-utils";
|
|
56
|
+
import { reconciledSelection } from "../../utils/package-selection";
|
|
55
57
|
import { onMount } from "svelte";
|
|
56
|
-
import {
|
|
58
|
+
import { SvelteMap } from "svelte/reactivity";
|
|
59
|
+
import { derived, readable, toStore, writable } from "svelte/store";
|
|
60
|
+
import { setWindowSizeContext } from "../../stores/window-size";
|
|
61
|
+
import {
|
|
62
|
+
setRenderedPackagesContext,
|
|
63
|
+
type RenderedPackages,
|
|
64
|
+
} from "../../stores/rendered-packages";
|
|
65
|
+
import {
|
|
66
|
+
openDefaultSelection,
|
|
67
|
+
setDefaultSelectionContext,
|
|
68
|
+
} from "../../stores/default-selection";
|
|
57
69
|
import Stack from "../stack/Stack.svelte";
|
|
58
70
|
import {
|
|
59
71
|
firstContentIsHeroMedia,
|
|
@@ -469,6 +481,76 @@
|
|
|
469
481
|
|
|
470
482
|
setPackageInfoContext(packageInfo);
|
|
471
483
|
|
|
484
|
+
let paywallWidth = $state<number>();
|
|
485
|
+
let paywallHeight = $state<number>();
|
|
486
|
+
// Window size rules evaluate against the paywall's own box, as the native
|
|
487
|
+
// SDKs measure the paywall's bounds rather than the screen.
|
|
488
|
+
setWindowSizeContext(
|
|
489
|
+
toStore(() =>
|
|
490
|
+
paywallWidth == null || paywallHeight == null
|
|
491
|
+
? null
|
|
492
|
+
: { width: paywallWidth, height: paywallHeight },
|
|
493
|
+
),
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
// Without ResizeObserver the size stays unknown, so window size rules don't
|
|
497
|
+
// match instead of the paywall failing to render.
|
|
498
|
+
function measurePaywall(node: HTMLElement) {
|
|
499
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
500
|
+
// A hidden paywall measures 0 by 0, which says nothing about its size.
|
|
501
|
+
const isHidden = (width: number, height: number) =>
|
|
502
|
+
width === 0 && height === 0;
|
|
503
|
+
// Measure once before the first paint, so a wide paywall doesn't flash its
|
|
504
|
+
// narrow layout. The offset size is in whole px; ResizeObserver then
|
|
505
|
+
// reports the fractional box.
|
|
506
|
+
if (!isHidden(node.offsetWidth, node.offsetHeight)) {
|
|
507
|
+
paywallWidth = node.offsetWidth;
|
|
508
|
+
paywallHeight = node.offsetHeight;
|
|
509
|
+
}
|
|
510
|
+
let frame = 0;
|
|
511
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
512
|
+
const box = entry?.borderBoxSize?.[0];
|
|
513
|
+
const width = box?.inlineSize ?? entry?.contentRect.width ?? 0;
|
|
514
|
+
const height = box?.blockSize ?? entry?.contentRect.height ?? 0;
|
|
515
|
+
if (isHidden(width, height)) return;
|
|
516
|
+
// Next frame, so a rule that resizes the paywall doesn't set off
|
|
517
|
+
// ResizeObserver's loop error.
|
|
518
|
+
cancelAnimationFrame(frame);
|
|
519
|
+
frame = requestAnimationFrame(() => {
|
|
520
|
+
paywallWidth = width;
|
|
521
|
+
paywallHeight = height;
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
observer.observe(node);
|
|
525
|
+
return {
|
|
526
|
+
destroy: () => {
|
|
527
|
+
observer.disconnect();
|
|
528
|
+
cancelAnimationFrame(frame);
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
setDefaultSelectionContext(openDefaultSelection());
|
|
534
|
+
const renderedPackages: RenderedPackages = new SvelteMap();
|
|
535
|
+
setRenderedPackagesContext(renderedPackages);
|
|
536
|
+
const packagePlacements = collectPackagePlacements(base);
|
|
537
|
+
|
|
538
|
+
// Moves the selection off a package a rule hides, so it can't stay selected
|
|
539
|
+
// and purchasable. Waits for the first measurement when there will be one,
|
|
540
|
+
// since window size rules can't be evaluated before it.
|
|
541
|
+
const willMeasure = typeof ResizeObserver !== "undefined";
|
|
542
|
+
$effect(() => {
|
|
543
|
+
if (willMeasure && (paywallWidth == null || paywallHeight == null)) return;
|
|
544
|
+
const replacement = reconciledSelection(
|
|
545
|
+
$selectedPackageId,
|
|
546
|
+
renderedPackages.values(),
|
|
547
|
+
packagePlacements,
|
|
548
|
+
);
|
|
549
|
+
if (replacement !== undefined) {
|
|
550
|
+
selectedPackageId.set(replacement);
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
|
|
472
554
|
onMount(() => {
|
|
473
555
|
registerFonts(uiConfig);
|
|
474
556
|
});
|
|
@@ -496,7 +578,7 @@
|
|
|
496
578
|
<svelte:boundary onerror={onError}>
|
|
497
579
|
<!-- Outside the root: filter/transform on `.blur` would clip the fixed backdrop to the paywall box. -->
|
|
498
580
|
<ViewportBackdrop model={viewportBackdropModel} blur={!!sheet} />
|
|
499
|
-
<div class={paywallClass} style={paywallStyle}>
|
|
581
|
+
<div class={paywallClass} style={paywallStyle} use:measurePaywall>
|
|
500
582
|
{#if header}
|
|
501
583
|
<div
|
|
502
584
|
class="header-wrapper"
|
|
@@ -515,6 +597,7 @@
|
|
|
515
597
|
bottom: (stack.padding?.bottom ?? 0) + footerHeight,
|
|
516
598
|
}
|
|
517
599
|
: stack.padding}
|
|
600
|
+
rootStack
|
|
518
601
|
class="paywall-content-scroll"
|
|
519
602
|
style={mapPaywallContentStackStyle(
|
|
520
603
|
stack.size.height,
|
|
@@ -552,7 +635,10 @@
|
|
|
552
635
|
</div>
|
|
553
636
|
|
|
554
637
|
{#if sheet}
|
|
555
|
-
|
|
638
|
+
<!-- Keyed, so opening another sheet from a sheet starts it fresh. -->
|
|
639
|
+
{#key sheet.props.id}
|
|
640
|
+
<Sheet {...sheet.props} />
|
|
641
|
+
{/key}
|
|
556
642
|
{/if}
|
|
557
643
|
</svelte:boundary>
|
|
558
644
|
|
|
@@ -605,10 +691,10 @@
|
|
|
605
691
|
flex-direction: column;
|
|
606
692
|
min-height: 0;
|
|
607
693
|
|
|
694
|
+
/* The root stack sets its own overflow: rootScrollStyle (stack-scroll.ts). */
|
|
608
695
|
& > :global(.paywall-content-scroll) {
|
|
609
696
|
flex: 1 1 auto;
|
|
610
697
|
min-height: 0;
|
|
611
|
-
overflow-y: auto;
|
|
612
698
|
}
|
|
613
699
|
}
|
|
614
700
|
|
|
@@ -16,9 +16,19 @@
|
|
|
16
16
|
import { resolveOverrideProperties } from "../../utils/style-utils";
|
|
17
17
|
import { onMount } from "svelte";
|
|
18
18
|
import Stack from "../stack/Stack.svelte";
|
|
19
|
+
import { sheetScrollBound } from "../stack/stack-scroll";
|
|
20
|
+
import { setPackageSheetContext } from "../../stores/rendered-packages";
|
|
21
|
+
import { setStackDimensionContext } from "../stack/stack-dimension-context";
|
|
22
|
+
import {
|
|
23
|
+
openDefaultSelection,
|
|
24
|
+
setDefaultSelectionContext,
|
|
25
|
+
} from "../../stores/default-selection";
|
|
19
26
|
|
|
20
27
|
const props: SheetProps = $props();
|
|
21
|
-
const { stack } = props;
|
|
28
|
+
const { stack, id } = props;
|
|
29
|
+
|
|
30
|
+
setDefaultSelectionContext(openDefaultSelection());
|
|
31
|
+
setPackageSheetContext(id);
|
|
22
32
|
|
|
23
33
|
const buildConditionContext = useConditionContext({ state: true });
|
|
24
34
|
const { size, background, background_blur, position } = $derived.by(() => {
|
|
@@ -28,6 +38,20 @@
|
|
|
28
38
|
};
|
|
29
39
|
});
|
|
30
40
|
|
|
41
|
+
const scrollBound = $derived(
|
|
42
|
+
sheetScrollBound(
|
|
43
|
+
{
|
|
44
|
+
...stack,
|
|
45
|
+
...resolveOverrideProperties(buildConditionContext(), stack.overrides),
|
|
46
|
+
},
|
|
47
|
+
size.height.type,
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
// While the sheet is a column, its stack sizes as a vertical stack's child.
|
|
51
|
+
setStackDimensionContext(() =>
|
|
52
|
+
scrollBound != null ? "vertical" : undefined,
|
|
53
|
+
);
|
|
54
|
+
|
|
31
55
|
const getColorMode = getColorModeContext();
|
|
32
56
|
const colorMode = $derived(getColorMode());
|
|
33
57
|
|
|
@@ -54,6 +78,12 @@
|
|
|
54
78
|
clipOverflow: true,
|
|
55
79
|
}),
|
|
56
80
|
...mapBackground(colorMode, null, background),
|
|
81
|
+
// A column, so a scrolling stack shrinks to the sheet, margins included.
|
|
82
|
+
...(scrollBound != null
|
|
83
|
+
? { display: "flex", "flex-direction": "column" }
|
|
84
|
+
: {}),
|
|
85
|
+
...(scrollBound === "screen" ? { "max-height": "100%" } : {}),
|
|
86
|
+
...(scrollBound === "full-screen" ? { height: "100%" } : {}),
|
|
57
87
|
}),
|
|
58
88
|
);
|
|
59
89
|
|