@revenuecat/purchases-ui-js 4.14.2 → 4.16.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.
Files changed (39) hide show
  1. package/dist/components/button/ButtonNode.svelte +6 -0
  2. package/dist/components/package/Package.svelte +82 -10
  3. package/dist/components/paywall/Paywall.stories.svelte +335 -1
  4. package/dist/components/paywall/Paywall.svelte +95 -4
  5. package/dist/components/paywall/Paywall.svelte.d.ts +1 -0
  6. package/dist/components/paywall/Sheet.svelte +31 -1
  7. package/dist/components/paywall/fixtures/stack-scroll-paywall.d.ts +40 -0
  8. package/dist/components/paywall/fixtures/stack-scroll-paywall.js +280 -0
  9. package/dist/components/paywall/fixtures/window-rule-packages-paywall.d.ts +14 -0
  10. package/dist/components/paywall/fixtures/window-rule-packages-paywall.js +89 -0
  11. package/dist/components/paywall/fixtures/window-size-paywall.d.ts +2 -0
  12. package/dist/components/paywall/fixtures/window-size-paywall.js +48 -0
  13. package/dist/components/stack/Stack.svelte +87 -9
  14. package/dist/components/stack/Stack.svelte.d.ts +2 -0
  15. package/dist/components/stack/stack-scroll.d.ts +59 -0
  16. package/dist/components/stack/stack-scroll.js +162 -0
  17. package/dist/components/tabs/Tabs.svelte +11 -0
  18. package/dist/components/workflows/Screen.svelte +3 -0
  19. package/dist/components/workflows/Screen.svelte.d.ts +1 -0
  20. package/dist/components/workflows/Workflow.svelte +3 -0
  21. package/dist/components/workflows/Workflow.svelte.d.ts +1 -0
  22. package/dist/stores/condition-context.svelte.js +3 -0
  23. package/dist/stores/default-selection.d.ts +17 -0
  24. package/dist/stores/default-selection.js +21 -0
  25. package/dist/stores/rendered-packages.d.ts +10 -0
  26. package/dist/stores/rendered-packages.js +17 -0
  27. package/dist/stores/window-size.d.ts +10 -0
  28. package/dist/stores/window-size.js +10 -0
  29. package/dist/types/components/button.d.ts +1 -1
  30. package/dist/types/components/package.d.ts +1 -0
  31. package/dist/types/components/stack.d.ts +6 -0
  32. package/dist/types/overrides.d.ts +15 -1
  33. package/dist/types/overrides.js +3 -0
  34. package/dist/types/paywall-component-interaction.d.ts +1 -1
  35. package/dist/utils/package-selection.d.ts +26 -0
  36. package/dist/utils/package-selection.js +47 -0
  37. package/dist/utils/style-utils.d.ts +12 -0
  38. package/dist/utils/style-utils.js +59 -0
  39. package/package.json +2 -2
@@ -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
 
@@ -0,0 +1,40 @@
1
+ import type { PaywallData } from "../../../types/paywall";
2
+ /** A Fit column that scrolls above a fixed box; the root doesn't scroll. */
3
+ export declare const STACKED_FIT_SCROLL_PAYWALL: PaywallData;
4
+ /** Two Fit columns side by side, each scrolling on its own. */
5
+ export declare const SIDE_BY_SIDE_SCROLL_PAYWALL: PaywallData;
6
+ /**
7
+ * A centered row with a set height: the short Fit column keeps the row's
8
+ * centering, and the tall one stops at the row and scrolls.
9
+ */
10
+ export declare const CENTERED_ROW_SCROLL_PAYWALL: PaywallData;
11
+ /** A row wider than the paywall that scrolls sideways. */
12
+ export declare const HORIZONTAL_SCROLL_PAYWALL: PaywallData;
13
+ /** Content taller than the paywall with the root's scrolling turned off. */
14
+ export declare const ROOT_NO_SCROLL_PAYWALL: PaywallData;
15
+ /**
16
+ * Stacked columns that go side by side once the paywall is 600 wide, with the
17
+ * root's scrolling off there, so each column scrolls on its own.
18
+ */
19
+ export declare const WINDOW_RULE_COLUMNS_PAYWALL: PaywallData;
20
+ /** Scrolling columns whose content is centered and end-aligned. */
21
+ export declare const ALIGNED_SCROLL_PAYWALL: PaywallData;
22
+ /**
23
+ * A bordered column with a badge, and a rounded one over a video, both
24
+ * scrolling: the border, badge and video stay put.
25
+ */
26
+ export declare const DECORATED_SCROLL_PAYWALL: PaywallData;
27
+ /**
28
+ * A Fit stack, holding a Fit column that scrolls (directly or as a package's
29
+ * stack), above a fixed box: the wrapper gives way so the box stays in view.
30
+ */
31
+ export declare const fitAroundFitScrollPaywall: (inPackage: boolean) => PaywallData;
32
+ /** A sheet whose Fit contents scroll: it stops at the screen and they scroll. */
33
+ export declare const SCROLLING_FIT_SHEET_PAYWALL: PaywallData;
34
+ /** A Fit sheet with Fill contents that scroll: it takes the screen's height. */
35
+ export declare const SCROLLING_FILL_IN_FIT_SHEET_PAYWALL: PaywallData;
36
+ /**
37
+ * A fixed-height sheet whose Fill contents, with large margins, scroll on
38
+ * their own, inside the margins.
39
+ */
40
+ export declare const SCROLLING_FILL_SHEET_PAYWALL: PaywallData;
@@ -0,0 +1,280 @@
1
+ import { STORY_BACKGROUND_VIDEO_FILL } from "../../../stories/video-background-story-fixture";
2
+ import { createTextComponent } from "./helpers";
3
+ // Fixtures for stack scrolling (`overflow`), for the bounded-frame stories in
4
+ // Paywall.stories.svelte.
5
+ const VERTICAL = {
6
+ type: "vertical",
7
+ alignment: "center",
8
+ distribution: "start",
9
+ };
10
+ const FIT = { width: { type: "fit" }, height: { type: "fit" } };
11
+ const ZERO = { bottom: 0, leading: 0, top: 0, trailing: 0 };
12
+ const stack = (props) => ({
13
+ type: "stack",
14
+ name: props.id,
15
+ components: [],
16
+ dimension: VERTICAL,
17
+ size: FIT,
18
+ background: null,
19
+ background_color: null,
20
+ badge: null,
21
+ border: null,
22
+ margin: ZERO,
23
+ padding: ZERO,
24
+ shadow: null,
25
+ shape: null,
26
+ spacing: 8,
27
+ ...props,
28
+ });
29
+ const box = (id, width, height, hex) => stack({
30
+ id,
31
+ size: {
32
+ width: { type: "fixed", value: width },
33
+ height: { type: "fixed", value: height },
34
+ },
35
+ background_color: { light: { type: "hex", value: hex } },
36
+ });
37
+ const rows = (id, count, hex) => Array.from({ length: count }, (_, index) => box(`${id}-${index}`, 140, 100, hex));
38
+ const paywall = (id, root) => ({
39
+ id,
40
+ default_locale: "en_US",
41
+ components_localizations: { en_US: {} },
42
+ components_config: {
43
+ base: {
44
+ background: {
45
+ type: "color",
46
+ value: { light: { type: "hex", value: "#ffffff" } },
47
+ },
48
+ stack: stack({
49
+ id: "root",
50
+ size: { width: { type: "fill" }, height: { type: "fill" } },
51
+ ...root,
52
+ }),
53
+ },
54
+ },
55
+ });
56
+ const fitScrollingColumn = (id, hex) => stack({ id, overflow: "scroll", components: rows(id, 14, hex) });
57
+ /** A Fit column that scrolls above a fixed box; the root doesn't scroll. */
58
+ export const STACKED_FIT_SCROLL_PAYWALL = paywall("stacked_fit_scroll", {
59
+ overflow: "default",
60
+ components: [
61
+ fitScrollingColumn("column", "#6366f1"),
62
+ box("offer", 200, 120, "#11d483"),
63
+ ],
64
+ });
65
+ /** Two Fit columns side by side, each scrolling on its own. */
66
+ export const SIDE_BY_SIDE_SCROLL_PAYWALL = paywall("side_by_side_scroll", {
67
+ overflow: "default",
68
+ dimension: { type: "horizontal", alignment: "top", distribution: "start" },
69
+ components: [
70
+ fitScrollingColumn("column-a", "#6366f1"),
71
+ fitScrollingColumn("column-b", "#ff5f5f"),
72
+ ],
73
+ });
74
+ /**
75
+ * A centered row with a set height: the short Fit column keeps the row's
76
+ * centering, and the tall one stops at the row and scrolls.
77
+ */
78
+ export const CENTERED_ROW_SCROLL_PAYWALL = paywall("centered_row_scroll", {
79
+ overflow: "default",
80
+ dimension: { type: "horizontal", alignment: "center", distribution: "start" },
81
+ components: [
82
+ stack({
83
+ id: "short",
84
+ overflow: "scroll",
85
+ components: rows("short", 2, "#11d483"),
86
+ }),
87
+ fitScrollingColumn("tall", "#6366f1"),
88
+ ],
89
+ });
90
+ /** A row wider than the paywall that scrolls sideways. */
91
+ export const HORIZONTAL_SCROLL_PAYWALL = paywall("horizontal_scroll", {
92
+ components: [
93
+ stack({
94
+ id: "row",
95
+ overflow: "scroll",
96
+ dimension: {
97
+ type: "horizontal",
98
+ alignment: "center",
99
+ distribution: "start",
100
+ },
101
+ size: { width: { type: "fill" }, height: { type: "fit" } },
102
+ components: Array.from({ length: 6 }, (_, index) => box(`card-${index}`, 200, 120, "#6366f1")),
103
+ }),
104
+ ],
105
+ });
106
+ /** Content taller than the paywall with the root's scrolling turned off. */
107
+ export const ROOT_NO_SCROLL_PAYWALL = paywall("root_no_scroll", {
108
+ overflow: "default",
109
+ components: rows("row", 14, "#ff5f5f"),
110
+ });
111
+ /**
112
+ * Stacked columns that go side by side once the paywall is 600 wide, with the
113
+ * root's scrolling off there, so each column scrolls on its own.
114
+ */
115
+ export const WINDOW_RULE_COLUMNS_PAYWALL = paywall("window_rule_columns", {
116
+ components: [
117
+ fitScrollingColumn("column-a", "#6366f1"),
118
+ fitScrollingColumn("column-b", "#ff5f5f"),
119
+ ],
120
+ overrides: [
121
+ {
122
+ conditions: [
123
+ { type: "window_width_condition", operator: ">=", value: 600 },
124
+ ],
125
+ properties: {
126
+ overflow: "default",
127
+ dimension: {
128
+ type: "horizontal",
129
+ alignment: "top",
130
+ distribution: "start",
131
+ },
132
+ },
133
+ },
134
+ ],
135
+ });
136
+ const scrollingColumn = (id, hex, props = {}) => stack({
137
+ id,
138
+ overflow: "scroll",
139
+ size: { width: { type: "fit" }, height: { type: "fixed", value: 300 } },
140
+ components: rows(id, 6, hex),
141
+ ...props,
142
+ });
143
+ /** Scrolling columns whose content is centered and end-aligned. */
144
+ export const ALIGNED_SCROLL_PAYWALL = paywall("aligned_scroll", {
145
+ overflow: "default",
146
+ dimension: { type: "horizontal", alignment: "top", distribution: "start" },
147
+ components: [
148
+ scrollingColumn("centered", "#6366f1", {
149
+ dimension: { ...VERTICAL, distribution: "center" },
150
+ }),
151
+ scrollingColumn("end", "#ff5f5f", {
152
+ dimension: { ...VERTICAL, distribution: "end" },
153
+ }),
154
+ ],
155
+ });
156
+ const ROUNDED = {
157
+ type: "rectangle",
158
+ corners: {
159
+ top_leading: 24,
160
+ top_trailing: 24,
161
+ bottom_leading: 24,
162
+ bottom_trailing: 24,
163
+ },
164
+ };
165
+ /**
166
+ * A bordered column with a badge, and a rounded one over a video, both
167
+ * scrolling: the border, badge and video stay put.
168
+ */
169
+ export const DECORATED_SCROLL_PAYWALL = paywall("decorated_scroll", {
170
+ overflow: "default",
171
+ dimension: { type: "horizontal", alignment: "top", distribution: "start" },
172
+ spacing: 24,
173
+ padding: { top: 24, bottom: 0, leading: 24, trailing: 0 },
174
+ components: [
175
+ scrollingColumn("bordered", "#6366f1", {
176
+ shape: ROUNDED,
177
+ border: {
178
+ color: { light: { type: "hex", value: "#11d483" } },
179
+ width: 10,
180
+ },
181
+ badge: {
182
+ style: "overlay",
183
+ alignment: "top_trailing",
184
+ stack: box("badge", 48, 24, "#ffb800"),
185
+ },
186
+ }),
187
+ scrollingColumn("video", "#ff5f5f", {
188
+ shape: ROUNDED,
189
+ background: STORY_BACKGROUND_VIDEO_FILL,
190
+ }),
191
+ ],
192
+ });
193
+ /**
194
+ * A Fit stack, holding a Fit column that scrolls (directly or as a package's
195
+ * stack), above a fixed box: the wrapper gives way so the box stays in view.
196
+ */
197
+ export const fitAroundFitScrollPaywall = (inPackage) => {
198
+ const column = fitScrollingColumn("column", "#6366f1");
199
+ const scroller = inPackage
200
+ ? {
201
+ type: "package",
202
+ id: "package",
203
+ name: "package",
204
+ package_id: "$rc_monthly",
205
+ is_selected_by_default: true,
206
+ stack: column,
207
+ }
208
+ : column;
209
+ return paywall(`fit_around_fit_scroll_${inPackage ? "package" : "stack"}`, {
210
+ overflow: "default",
211
+ components: [
212
+ stack({ id: "wrapper", components: [scroller] }),
213
+ box("offer", 200, 120, "#11d483"),
214
+ ],
215
+ });
216
+ };
217
+ const sheetPaywall = (id, sheet) => ({
218
+ ...paywall(id, {
219
+ components: [
220
+ {
221
+ type: "button",
222
+ id: "open-sheet",
223
+ name: "open-sheet",
224
+ action: {
225
+ type: "navigate_to",
226
+ destination: "sheet",
227
+ sheet: {
228
+ type: "sheet",
229
+ id: `${id}-sheet`,
230
+ name: "sheet",
231
+ size: { width: { type: "fill" }, height: { type: "fit" } },
232
+ background_blur: false,
233
+ ...sheet,
234
+ },
235
+ },
236
+ stack: stack({
237
+ id: "open-sheet-stack",
238
+ components: [
239
+ createTextComponent({
240
+ id: "open-sheet-text",
241
+ textLid: "open_sheet",
242
+ }),
243
+ ],
244
+ }),
245
+ },
246
+ ],
247
+ }),
248
+ components_localizations: { en_US: { open_sheet: "Open sheet" } },
249
+ });
250
+ /** A sheet whose Fit contents scroll: it stops at the screen and they scroll. */
251
+ export const SCROLLING_FIT_SHEET_PAYWALL = sheetPaywall("scrolling_fit_sheet", {
252
+ stack: stack({
253
+ id: "sheet-stack",
254
+ overflow: "scroll",
255
+ components: rows("sheet-row", 14, "#6366f1"),
256
+ }),
257
+ });
258
+ /** A Fit sheet with Fill contents that scroll: it takes the screen's height. */
259
+ export const SCROLLING_FILL_IN_FIT_SHEET_PAYWALL = sheetPaywall("scrolling_fill_in_fit_sheet", {
260
+ stack: stack({
261
+ id: "sheet-stack",
262
+ overflow: "scroll",
263
+ size: { width: { type: "fill" }, height: { type: "fill" } },
264
+ components: rows("sheet-row", 14, "#ff5f5f"),
265
+ }),
266
+ });
267
+ /**
268
+ * A fixed-height sheet whose Fill contents, with large margins, scroll on
269
+ * their own, inside the margins.
270
+ */
271
+ export const SCROLLING_FILL_SHEET_PAYWALL = sheetPaywall("scrolling_fill_sheet", {
272
+ size: { width: { type: "fill" }, height: { type: "fixed", value: 400 } },
273
+ stack: stack({
274
+ id: "sheet-stack",
275
+ overflow: "scroll",
276
+ size: { width: { type: "fill" }, height: { type: "fill" } },
277
+ margin: { top: 40, bottom: 40, leading: 40, trailing: 40 },
278
+ components: rows("sheet-row", 14, "#ff5f5f"),
279
+ }),
280
+ });
@@ -0,0 +1,14 @@
1
+ import type { Component } from "../../../types/component";
2
+ import type { PaywallData } from "../../../types/paywall";
3
+ import type { PackageProps } from "../../../types/components/package";
4
+ import type { OverrideCondition } from "../../../types/overrides";
5
+ export declare const WIDE: OverrideCondition[];
6
+ /** A package card whose border shows when it's selected. */
7
+ export declare const plan: (packageId: string, label: string, props?: Partial<PackageProps>) => PackageProps;
8
+ /** `components` above a purchase button, with the plan labels localized. */
9
+ export declare const packagesPaywall: (components: Component[], localizations?: Record<string, string>) => PaywallData;
10
+ /**
11
+ * The annual plan, selected by default, hides at 600 wide and up, where a
12
+ * monthly plan shows instead, so the selection moves to it.
13
+ */
14
+ export declare const WINDOW_RULE_PACKAGES_PAYWALL: PaywallData;
@@ -0,0 +1,89 @@
1
+ import { createPackageComponent, createStack, createTextComponent, } from "./helpers";
2
+ export const WIDE = [
3
+ { type: "window_width_condition", operator: ">=", value: 600 },
4
+ ];
5
+ /** A package card whose border shows when it's selected. */
6
+ export const plan = (packageId, label, props = {}) => {
7
+ const pkg = createPackageComponent({
8
+ id: `${label}-${packageId}`,
9
+ name: label,
10
+ package_id: packageId,
11
+ is_selected_by_default: false,
12
+ components: [createTextComponent({ id: `${label}-text`, textLid: label })],
13
+ });
14
+ pkg.stack = {
15
+ ...pkg.stack,
16
+ size: { width: { type: "fill" }, height: { type: "fit" } },
17
+ padding: { top: 16, bottom: 16, leading: 16, trailing: 16 },
18
+ border: { color: { light: { type: "hex", value: "#dddddd" } }, width: 2 },
19
+ overrides: [
20
+ {
21
+ conditions: [{ type: "selected" }],
22
+ properties: {
23
+ border: {
24
+ color: { light: { type: "hex", value: "#6366f1" } },
25
+ width: 2,
26
+ },
27
+ },
28
+ },
29
+ ],
30
+ };
31
+ return { ...pkg, ...props };
32
+ };
33
+ const purchaseButton = {
34
+ type: "purchase_button",
35
+ id: "purchase",
36
+ name: "Purchase",
37
+ stack: createStack({
38
+ id: "purchase-stack",
39
+ name: "Purchase Stack",
40
+ components: [createTextComponent({ id: "purchase-text", textLid: "buy" })],
41
+ }),
42
+ };
43
+ /** `components` above a purchase button, with the plan labels localized. */
44
+ export const packagesPaywall = (components, localizations = {}) => ({
45
+ id: "window_rule_packages",
46
+ default_locale: "en_US",
47
+ components_localizations: {
48
+ en_US: {
49
+ annual: "Annual (hidden at 600 wide and up)",
50
+ monthly: "Monthly (shown at 600 wide and up)",
51
+ weekly: "Weekly",
52
+ buy: "Purchase",
53
+ ...localizations,
54
+ },
55
+ },
56
+ components_config: {
57
+ base: {
58
+ background: {
59
+ type: "color",
60
+ value: { light: { type: "hex", value: "#ffffff" } },
61
+ },
62
+ stack: {
63
+ ...createStack({
64
+ id: "root",
65
+ name: "root",
66
+ components: [...components, purchaseButton],
67
+ }),
68
+ size: { width: { type: "fill" }, height: { type: "fill" } },
69
+ padding: { top: 16, bottom: 16, leading: 16, trailing: 16 },
70
+ spacing: 12,
71
+ },
72
+ },
73
+ },
74
+ });
75
+ /**
76
+ * The annual plan, selected by default, hides at 600 wide and up, where a
77
+ * monthly plan shows instead, so the selection moves to it.
78
+ */
79
+ export const WINDOW_RULE_PACKAGES_PAYWALL = packagesPaywall([
80
+ plan("$rc_annual", "annual", {
81
+ is_selected_by_default: true,
82
+ overrides: [{ conditions: WIDE, properties: { visible: false } }],
83
+ }),
84
+ plan("$rc_monthly", "monthly", {
85
+ visible: false,
86
+ overrides: [{ conditions: WIDE, properties: { visible: true } }],
87
+ }),
88
+ plan("$rc_weekly", "weekly"),
89
+ ]);
@@ -0,0 +1,2 @@
1
+ import type { PaywallData } from "../../../types/paywall";
2
+ export declare const WINDOW_SIZE_PAYWALL: PaywallData;
@@ -0,0 +1,48 @@
1
+ import { createStack, createTextComponent } from "./helpers";
2
+ // One text per window size rule: each reads its matched copy while its rule
3
+ // matches the paywall's box.
4
+ const layoutText = createTextComponent({
5
+ id: "layout-text",
6
+ textLid: "narrow",
7
+ });
8
+ layoutText.overrides = [
9
+ {
10
+ conditions: [
11
+ { type: "window_width_condition", operator: ">=", value: 600 },
12
+ ],
13
+ properties: { text_lid: "wide" },
14
+ },
15
+ ];
16
+ const orientationText = createTextComponent({
17
+ id: "orientation-text",
18
+ textLid: "portrait",
19
+ });
20
+ orientationText.overrides = [
21
+ {
22
+ conditions: [
23
+ { type: "window_aspect_ratio_condition", operator: ">", value: 1 },
24
+ ],
25
+ properties: { text_lid: "landscape" },
26
+ },
27
+ ];
28
+ export const WINDOW_SIZE_PAYWALL = {
29
+ id: "window-size-paywall",
30
+ default_locale: "en_US",
31
+ components_localizations: {
32
+ en_US: {
33
+ narrow: "Width under 600px",
34
+ wide: "Width 600px or more",
35
+ portrait: "Portrait or square",
36
+ landscape: "Landscape",
37
+ },
38
+ },
39
+ components_config: {
40
+ base: {
41
+ stack: createStack({
42
+ id: "root-stack",
43
+ name: "Root Stack",
44
+ components: [layoutText, orientationText],
45
+ }),
46
+ },
47
+ },
48
+ };
@@ -30,6 +30,15 @@
30
30
  mapStackSize,
31
31
  } from "./stack-utils";
32
32
  import { setStackDimensionContext } from "./stack-dimension-context";
33
+ import {
34
+ fitScrollerParentAxis,
35
+ fitScrollSizing,
36
+ rootScrollStyle,
37
+ setStackScrollContext,
38
+ setStackSetHeightContext,
39
+ stackHasSetHeight,
40
+ stackScrollAxis,
41
+ } from "./stack-scroll";
33
42
 
34
43
  interface MiscProps {
35
44
  onclick?: () => void;
@@ -37,10 +46,18 @@
37
46
  style?: CSS;
38
47
  class?: string;
39
48
  testId?: string;
49
+ /** The paywall's root stack, which the paywall scrolls vertically. */
50
+ rootStack?: boolean;
40
51
  }
41
52
 
42
53
  const props: StackProps & MiscProps = $props();
43
- const { onclick, children, class: classProp, testId } = props;
54
+ const {
55
+ onclick,
56
+ children,
57
+ class: classProp,
58
+ testId,
59
+ rootStack = false,
60
+ } = props;
44
61
 
45
62
  const buildConditionContext = useConditionContext({
46
63
  state: true,
@@ -60,6 +77,7 @@
60
77
  shadow,
61
78
  badge,
62
79
  visible,
80
+ overflow,
63
81
  } = $derived.by(() => {
64
82
  return {
65
83
  ...props,
@@ -70,6 +88,25 @@
70
88
  () => dimension.type,
71
89
  );
72
90
 
91
+ const scrollAxis = $derived(
92
+ overflow === "scroll" ? stackScrollAxis(dimension.type, rootStack) : null,
93
+ );
94
+ // The root scrolls vertically unless its overflow is "default".
95
+ const rootScrolls = $derived(rootStack && overflow !== "default");
96
+ const getParentHasSetHeight = setStackSetHeightContext(() =>
97
+ stackHasSetHeight(size, rootStack),
98
+ );
99
+ const getParentScrollAxis = setStackScrollContext(
100
+ () => scrollAxis ?? (rootScrolls ? "y" : null),
101
+ );
102
+ // Like the SDKs' scroll view, an inner box scrolls the padded contents while
103
+ // the stack keeps its background, border and badge in place.
104
+ const scrollsInside = $derived(scrollAxis !== null && !rootStack);
105
+ const sizingAxis = $derived(
106
+ scrollAxis ??
107
+ fitScrollerParentAxis(size, components, buildConditionContext()),
108
+ );
109
+
73
110
  const getColorMode = getColorModeContext();
74
111
  const colorMode = $derived(getColorMode());
75
112
 
@@ -82,15 +119,28 @@
82
119
  });
83
120
  const borderRadiusCss = $derived(mapBorderRadius(shape));
84
121
 
122
+ const layoutStyle = $derived({
123
+ ...mapDimension(dimension),
124
+ gap: px(spacing),
125
+ padding: mapSpacing(padding),
126
+ });
127
+ const scrollDirection = $derived(scrollAxis === "x" ? "row" : "column");
128
+
85
129
  const stackStyle = $derived(
86
130
  css({
87
131
  display: "flex",
88
132
  position: "relative",
89
133
  ...mapStackSize(size, getParentStackDimension?.()),
90
- ...mapDimension(dimension),
91
- gap: px(spacing),
134
+ ...fitScrollSizing(
135
+ sizingAxis,
136
+ size,
137
+ getParentStackDimension?.(),
138
+ getParentScrollAxis?.(),
139
+ getParentHasSetHeight?.(),
140
+ ),
141
+ ...(scrollsInside ? { "flex-direction": scrollDirection } : layoutStyle),
142
+ ...(rootStack ? rootScrollStyle(overflow, scrollAxis) : {}),
92
143
  margin: mapSpacing(margin),
93
- padding: mapSpacing(padding),
94
144
  ...mapBackground(colorMode, background_color, background),
95
145
  ...mapBorder(colorMode, border),
96
146
  "border-radius": borderRadiusCss,
@@ -104,6 +154,24 @@
104
154
  }),
105
155
  );
106
156
 
157
+ // Above the stack's overlay, so it can be scrolled and clicked.
158
+ const scrollViewportStyle = $derived(
159
+ css({
160
+ position: "relative",
161
+ display: "flex",
162
+ "flex-direction": scrollDirection,
163
+ flex: "1 1 auto",
164
+ [scrollAxis === "x" ? "min-width" : "min-height"]: 0,
165
+ "overflow-x": scrollAxis === "x" ? "auto" : "hidden",
166
+ "overflow-y": scrollAxis === "y" ? "auto" : "hidden",
167
+ }),
168
+ );
169
+ // At least as big as the viewport and never smaller than its contents, so
170
+ // centered or end-aligned contents can always be scrolled to.
171
+ const scrollContentStyle = $derived(
172
+ css({ display: "flex", flex: "1 0 auto", ...layoutStyle }),
173
+ );
174
+
107
175
  const getLayerStyle = (index: number): string => {
108
176
  if (dimension.type !== "zlayer") {
109
177
  return "";
@@ -184,16 +252,26 @@
184
252
  </div>
185
253
  {/each}
186
254
  </div>
255
+ {@render children?.()}
256
+ {:else if scrollsInside}
257
+ <div class="rc-stack-scroll" style={scrollViewportStyle}>
258
+ <div style={scrollContentStyle}>
259
+ {@render contents()}
260
+ </div>
261
+ </div>
187
262
  {:else}
188
- {#each components as component}
189
- <Node nodeData={component} />
190
- {/each}
263
+ {@render contents()}
191
264
  {/if}
192
-
193
- {@render children?.()}
194
265
  </svelte:element>
195
266
  {/if}
196
267
 
268
+ {#snippet contents()}
269
+ {#each components as component}
270
+ <Node nodeData={component} />
271
+ {/each}
272
+ {@render children?.()}
273
+ {/snippet}
274
+
197
275
  <style>
198
276
  .stack {
199
277
  appearance: none;