@revenuecat/purchases-ui-js 4.8.22 → 4.8.23
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 +6 -0
- package/dist/components/input-text/InputText.stories.svelte +92 -0
- package/dist/components/input-text/InputText.stories.svelte.d.ts +19 -0
- package/dist/components/input-text/InputText.svelte +281 -32
- package/dist/components/input-text/InputTextTestWrapper.svelte +14 -4
- package/dist/components/input-text/InputTextTestWrapper.svelte.d.ts +6 -0
- package/dist/components/input-text/pattern.d.ts +16 -0
- package/dist/components/input-text/pattern.js +24 -0
- package/dist/components/input-text/validity-rule.d.ts +15 -0
- package/dist/components/input-text/validity-rule.js +40 -0
- package/dist/components/options/InputMultipleChoice.svelte +5 -2
- package/dist/components/options/InputSingleChoice.svelte +5 -2
- package/dist/components/paywall/Paywall.stories.svelte +26 -0
- package/dist/components/paywall/Paywall.svelte +12 -6
- package/dist/components/paywall/fixtures/hero-media-paywall.d.ts +3 -0
- package/dist/components/paywall/fixtures/hero-media-paywall.js +182 -0
- package/dist/components/paywall/paywall-utils.d.ts +2 -0
- package/dist/components/paywall/paywall-utils.js +22 -0
- package/dist/components/text/TextNode.svelte +2 -22
- package/dist/components/text/text-utils.d.ts +8 -0
- package/dist/components/text/text-utils.js +26 -0
- package/dist/components/workflows/Screen.svelte +5 -1
- package/dist/stores/inputValidation.d.ts +6 -3
- package/dist/stores/inputValidation.js +27 -4
- package/dist/types/components/input-text.d.ts +19 -0
- package/dist/types/overrides.d.ts +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,3 +9,9 @@ To build your library:
|
|
|
9
9
|
```bash
|
|
10
10
|
npm run package
|
|
11
11
|
```
|
|
12
|
+
|
|
13
|
+
## Releasing
|
|
14
|
+
|
|
15
|
+
Bump `version` in `package.json` in a PR. Once it lands on `main`, the Azure pipeline publishes that version to npm, skipping the publish if the version is already on the registry.
|
|
16
|
+
|
|
17
|
+
After publishing, the pipeline triggers the `dependency-update` action in [purchases-js](https://github.com/RevenueCat/purchases-js). That job compares the pinned `@revenuecat/purchases-ui-js` with the latest on npm and opens a bump PR there if it is behind. The call runs on every `main` build and is a no-op when nothing changed. It needs the `CIRCLE_TOKEN` secret in the pipeline variables.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import InputText from "./InputText.svelte";
|
|
3
|
+
import { componentDecorator } from "../../stories/component-decorator";
|
|
4
|
+
import { localizationDecorator } from "../../stories/localization-decorator";
|
|
5
|
+
import { paywallDecorator } from "../../stories/paywall-decorator";
|
|
6
|
+
import type { InputTextProps } from "../../types/components/input-text";
|
|
7
|
+
import { defineMeta } from "@storybook/addon-svelte-csf";
|
|
8
|
+
|
|
9
|
+
const defaultLocale = "en_US";
|
|
10
|
+
|
|
11
|
+
const { Story } = defineMeta({
|
|
12
|
+
title: "Components/InputText",
|
|
13
|
+
component: InputText,
|
|
14
|
+
decorators: [
|
|
15
|
+
componentDecorator(),
|
|
16
|
+
paywallDecorator(),
|
|
17
|
+
localizationDecorator({
|
|
18
|
+
defaultLocale,
|
|
19
|
+
localizations: {
|
|
20
|
+
[defaultLocale]: {
|
|
21
|
+
email_placeholder: "Enter your email",
|
|
22
|
+
zero_placeholder: "0",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
],
|
|
27
|
+
args: {
|
|
28
|
+
type: "input_text",
|
|
29
|
+
id: "input-text-1",
|
|
30
|
+
name: "Text Input",
|
|
31
|
+
placeholder_lid: "email_placeholder",
|
|
32
|
+
keyboard_type: "email",
|
|
33
|
+
capitalize: "none",
|
|
34
|
+
field_id: "email",
|
|
35
|
+
required: false,
|
|
36
|
+
size: {
|
|
37
|
+
width: { type: "fill" },
|
|
38
|
+
height: { type: "fit" },
|
|
39
|
+
},
|
|
40
|
+
margin: { top: 0, trailing: 0, bottom: 0, leading: 0 },
|
|
41
|
+
padding: { top: 12, trailing: 16, bottom: 12, leading: 16 },
|
|
42
|
+
font_size: 16,
|
|
43
|
+
horizontal_alignment: "leading",
|
|
44
|
+
color: {
|
|
45
|
+
light: { type: "hex", value: "#000000" },
|
|
46
|
+
},
|
|
47
|
+
placeholder_font_size: 16,
|
|
48
|
+
placeholder_horizontal_alignment: "leading",
|
|
49
|
+
placeholder_color: {
|
|
50
|
+
light: { type: "hex", value: "#A9A9A9" },
|
|
51
|
+
},
|
|
52
|
+
background_color: {
|
|
53
|
+
light: { type: "hex", value: "#F5F5F5" },
|
|
54
|
+
},
|
|
55
|
+
shape: {
|
|
56
|
+
type: "rectangle",
|
|
57
|
+
corners: {
|
|
58
|
+
top_leading: 8,
|
|
59
|
+
top_trailing: 8,
|
|
60
|
+
bottom_leading: 8,
|
|
61
|
+
bottom_trailing: 8,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
} satisfies InputTextProps,
|
|
65
|
+
});
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<Story name="Default" />
|
|
69
|
+
|
|
70
|
+
<!-- `fit` width sizes the input to its displayed text (the placeholder until a
|
|
71
|
+
value is typed), matching the editor preview. -->
|
|
72
|
+
<Story
|
|
73
|
+
name="Fit Width"
|
|
74
|
+
args={{
|
|
75
|
+
placeholder_lid: "zero_placeholder",
|
|
76
|
+
keyboard_type: "numeric",
|
|
77
|
+
field_id: "height_ft",
|
|
78
|
+
size: {
|
|
79
|
+
width: { type: "fit" },
|
|
80
|
+
height: { type: "fit" },
|
|
81
|
+
},
|
|
82
|
+
padding: { top: 0, trailing: 0, bottom: 0, leading: 0 },
|
|
83
|
+
font_size: 56,
|
|
84
|
+
font_weight_int: 700,
|
|
85
|
+
horizontal_alignment: "center",
|
|
86
|
+
placeholder_font_size: 56,
|
|
87
|
+
placeholder_font_weight_int: 700,
|
|
88
|
+
placeholder_horizontal_alignment: "center",
|
|
89
|
+
background_color: null,
|
|
90
|
+
shape: null,
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import InputText from "./InputText.svelte";
|
|
2
|
+
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
3
|
+
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
4
|
+
$$bindings?: Bindings;
|
|
5
|
+
} & Exports;
|
|
6
|
+
(internal: unknown, props: {
|
|
7
|
+
$$events?: Events;
|
|
8
|
+
$$slots?: Slots;
|
|
9
|
+
}): Exports & {
|
|
10
|
+
$set?: any;
|
|
11
|
+
$on?: any;
|
|
12
|
+
};
|
|
13
|
+
z_$$bindings?: Bindings;
|
|
14
|
+
}
|
|
15
|
+
declare const InputText: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
|
|
16
|
+
[evt: string]: CustomEvent<any>;
|
|
17
|
+
}, {}, {}, string>;
|
|
18
|
+
type InputText = InstanceType<typeof InputText>;
|
|
19
|
+
export default InputText;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type {
|
|
10
10
|
InputTextKeyboardType,
|
|
11
11
|
InputTextProps,
|
|
12
|
+
InputTextValidationRule,
|
|
12
13
|
} from "../../types/components/input-text";
|
|
13
14
|
import { mapBackground } from "../../utils/background-utils";
|
|
14
15
|
import {
|
|
@@ -25,14 +26,23 @@
|
|
|
25
26
|
} from "../../utils/font-utils";
|
|
26
27
|
import { resolveOverrideProperties } from "../../utils/style-utils";
|
|
27
28
|
import { replaceVariables } from "../../utils/variable-utils";
|
|
29
|
+
import { onDestroy } from "svelte";
|
|
28
30
|
import type { FormEventHandler, HTMLInputAttributes } from "svelte/elements";
|
|
29
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
getHtmlFromMarkdown,
|
|
33
|
+
mapTextColor,
|
|
34
|
+
markdownLinkUrlFromClick,
|
|
35
|
+
} from "../text/text-utils";
|
|
36
|
+
import { compilePattern } from "./pattern";
|
|
37
|
+
import { failingRuleFromValidity } from "./validity-rule";
|
|
30
38
|
|
|
31
39
|
const props: InputTextProps = $props();
|
|
32
40
|
|
|
33
41
|
let focused = $state(false);
|
|
34
42
|
let error = $state(false);
|
|
35
43
|
let valid = $state(!props.required);
|
|
44
|
+
let failedRule = $state<InputTextValidationRule | null>(null);
|
|
45
|
+
let currentValue = $state("");
|
|
36
46
|
|
|
37
47
|
const buildConditionContext = useConditionContext({ state: true });
|
|
38
48
|
|
|
@@ -62,6 +72,9 @@
|
|
|
62
72
|
placeholder_color,
|
|
63
73
|
background_color,
|
|
64
74
|
visible,
|
|
75
|
+
validation,
|
|
76
|
+
validation_error_lid,
|
|
77
|
+
validation_error_lids,
|
|
65
78
|
} = $derived.by(() => {
|
|
66
79
|
return {
|
|
67
80
|
...props,
|
|
@@ -91,8 +104,21 @@
|
|
|
91
104
|
}
|
|
92
105
|
}
|
|
93
106
|
|
|
107
|
+
function readValidity(input: HTMLInputElement): boolean {
|
|
108
|
+
if (compiledPattern?.mode === "custom") {
|
|
109
|
+
// Mirrors the native `pattern` semantics: empty values are left to
|
|
110
|
+
// `required`, everything else must match the whole value.
|
|
111
|
+
const mismatch =
|
|
112
|
+
input.value !== "" && !compiledPattern.regex.test(input.value);
|
|
113
|
+
input.setCustomValidity(mismatch ? "pattern" : "");
|
|
114
|
+
}
|
|
115
|
+
const { validity } = input;
|
|
116
|
+
failedRule = failingRuleFromValidity(validity);
|
|
117
|
+
return validity.valid;
|
|
118
|
+
}
|
|
119
|
+
|
|
94
120
|
function handleValueChange(input: HTMLInputElement) {
|
|
95
|
-
error = !input
|
|
121
|
+
error = !readValidity(input);
|
|
96
122
|
if (error) {
|
|
97
123
|
return;
|
|
98
124
|
}
|
|
@@ -101,7 +127,8 @@
|
|
|
101
127
|
|
|
102
128
|
const oninput: FormEventHandler<HTMLInputElement> = (event) => {
|
|
103
129
|
const input = event.currentTarget;
|
|
104
|
-
|
|
130
|
+
currentValue = input.value;
|
|
131
|
+
valid = readValidity(input);
|
|
105
132
|
// Keep the propagated value in lockstep with `valid` so Continue can
|
|
106
133
|
// never unlock before the workflow has received the current value.
|
|
107
134
|
if (valid) {
|
|
@@ -114,7 +141,7 @@
|
|
|
114
141
|
|
|
115
142
|
const onblur: FormEventHandler<HTMLInputElement> = (event) => {
|
|
116
143
|
focused = false;
|
|
117
|
-
valid = event.currentTarget
|
|
144
|
+
valid = readValidity(event.currentTarget);
|
|
118
145
|
handleValueChange(event.currentTarget);
|
|
119
146
|
};
|
|
120
147
|
|
|
@@ -127,8 +154,26 @@
|
|
|
127
154
|
onInputChanged,
|
|
128
155
|
onSensitiveInputChanged,
|
|
129
156
|
onReservedAttributeChanged,
|
|
157
|
+
emitComponentInteraction,
|
|
158
|
+
onNavigateToUrl,
|
|
130
159
|
} = getPaywallContext();
|
|
131
160
|
|
|
161
|
+
// Links in the error copy go through the host like text component links do:
|
|
162
|
+
// some hosts block a bare `target="_blank"` anchor.
|
|
163
|
+
const onErrorClick = (event: MouseEvent) => {
|
|
164
|
+
const url = markdownLinkUrlFromClick(event);
|
|
165
|
+
if (!url) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
emitComponentInteraction({
|
|
169
|
+
componentType: "text",
|
|
170
|
+
componentName: props.name,
|
|
171
|
+
componentValue: "navigate_to_url",
|
|
172
|
+
componentURL: url,
|
|
173
|
+
});
|
|
174
|
+
onNavigateToUrl?.(url);
|
|
175
|
+
};
|
|
176
|
+
|
|
132
177
|
const verticalAlignItems = $derived(
|
|
133
178
|
vertical_alignment === "top"
|
|
134
179
|
? "flex-start"
|
|
@@ -137,13 +182,34 @@
|
|
|
137
182
|
: null,
|
|
138
183
|
);
|
|
139
184
|
|
|
140
|
-
|
|
185
|
+
// `fill` and percentage heights resolve against the parent, so they must sit
|
|
186
|
+
// on the element that is the stack's flex item (the outer container); the
|
|
187
|
+
// bordered box then takes what is left above the error row. Fixed and fit
|
|
188
|
+
// heights stay on the bordered box so the error row never eats into them.
|
|
189
|
+
const heightOnContainer = $derived(
|
|
190
|
+
size.height.type === "fill" || size.height.type === "relative",
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// The margin lives on the outer container so the error text sits inside the
|
|
194
|
+
// component's margin box, directly below the bordered input.
|
|
195
|
+
const containerStyle = $derived(
|
|
141
196
|
css({
|
|
142
197
|
display: "flex",
|
|
143
|
-
|
|
198
|
+
"flex-direction": "column",
|
|
144
199
|
width: mapSize(size.width),
|
|
145
|
-
height: mapSize(size.height),
|
|
200
|
+
...(heightOnContainer ? { height: mapSize(size.height) } : {}),
|
|
146
201
|
margin: mapSpacing(margin),
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const wrapperStyle = $derived(
|
|
206
|
+
css({
|
|
207
|
+
display: "flex",
|
|
208
|
+
...(verticalAlignItems ? { "align-items": verticalAlignItems } : {}),
|
|
209
|
+
width: "100%",
|
|
210
|
+
...(heightOnContainer
|
|
211
|
+
? { flex: "1 1 auto", "min-height": "0" }
|
|
212
|
+
: { flex: "none", height: mapSize(size.height) }),
|
|
147
213
|
...mapBackground(colorMode, background_color, null),
|
|
148
214
|
...mapBorder(colorMode, border),
|
|
149
215
|
"border-radius": mapBorderRadius(shape),
|
|
@@ -151,11 +217,14 @@
|
|
|
151
217
|
}),
|
|
152
218
|
);
|
|
153
219
|
|
|
220
|
+
function resolveFontFamily(fontName: string | null | undefined): string {
|
|
221
|
+
const family = uiConfig.app.fonts[fontName ?? ""]?.web?.family;
|
|
222
|
+
return isFontRCFMManaged(fontName ?? "")
|
|
223
|
+
? getScopedFontFamily(family ?? "")
|
|
224
|
+
: "sans-serif";
|
|
225
|
+
}
|
|
226
|
+
|
|
154
227
|
const inputStyle = $derived.by(() => {
|
|
155
|
-
const font = uiConfig.app.fonts[font_name ?? ""];
|
|
156
|
-
const fontFamily = font?.web?.family;
|
|
157
|
-
const placeholderFont = uiConfig.app.fonts[placeholder_font_name ?? ""];
|
|
158
|
-
const placeholderFontFamily = placeholderFont?.web?.family;
|
|
159
228
|
const placeholderColor = mapTextColor(colorMode, placeholder_color);
|
|
160
229
|
|
|
161
230
|
return css({
|
|
@@ -166,9 +235,7 @@
|
|
|
166
235
|
TextAlignments[horizontal_alignment] || TextAlignments.leading,
|
|
167
236
|
"font-weight": font_weight_int ?? FontWeights.regular,
|
|
168
237
|
"font-size": `${font_size}px`,
|
|
169
|
-
"font-family":
|
|
170
|
-
? getScopedFontFamily(fontFamily ?? "")
|
|
171
|
-
: "sans-serif",
|
|
238
|
+
"font-family": resolveFontFamily(font_name),
|
|
172
239
|
|
|
173
240
|
"--placeholder-color": placeholderColor.color,
|
|
174
241
|
"--placeholder-background": placeholderColor.background,
|
|
@@ -181,11 +248,7 @@
|
|
|
181
248
|
"--placeholder-font-weight":
|
|
182
249
|
placeholder_font_weight_int ?? FontWeights.regular,
|
|
183
250
|
"--placeholder-font-size": `${placeholder_font_size}px`,
|
|
184
|
-
"--placeholder-font-family":
|
|
185
|
-
placeholder_font_name ?? "",
|
|
186
|
-
)
|
|
187
|
-
? getScopedFontFamily(placeholderFontFamily ?? "")
|
|
188
|
-
: "sans-serif",
|
|
251
|
+
"--placeholder-font-family": resolveFontFamily(placeholder_font_name),
|
|
189
252
|
});
|
|
190
253
|
});
|
|
191
254
|
|
|
@@ -195,7 +258,48 @@
|
|
|
195
258
|
replaceVariables(getLocalizedString(placeholder_lid), $variables),
|
|
196
259
|
);
|
|
197
260
|
|
|
261
|
+
const errorLid = $derived(
|
|
262
|
+
(failedRule ? validation_error_lids?.[failedRule] : null) ??
|
|
263
|
+
validation_error_lid ??
|
|
264
|
+
undefined,
|
|
265
|
+
);
|
|
266
|
+
const errorMessage = $derived(
|
|
267
|
+
replaceVariables(getLocalizedString(errorLid) ?? "", $variables),
|
|
268
|
+
);
|
|
269
|
+
// Same markdown subset as text components; the helper escapes HTML first so
|
|
270
|
+
// localization strings cannot reach the `{@html}` sink as markup.
|
|
271
|
+
const errorHtml = $derived(getHtmlFromMarkdown(errorMessage));
|
|
272
|
+
const errorId = $derived(`${props.id}-error`);
|
|
273
|
+
const showErrorMessage = $derived(error && errorMessage !== "");
|
|
274
|
+
// The error row is reserved as soon as any error copy is configured, so the
|
|
275
|
+
// message appearing never shifts the layout below the input. Inputs without
|
|
276
|
+
// copy keep their original footprint.
|
|
277
|
+
const hasErrorCopy = $derived(
|
|
278
|
+
validation_error_lid != null ||
|
|
279
|
+
Object.values(validation_error_lids ?? {}).some((lid) => lid != null),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const errorStyle = $derived(
|
|
283
|
+
css({
|
|
284
|
+
...mapTextColor(colorMode, color),
|
|
285
|
+
"text-align":
|
|
286
|
+
TextAlignments[horizontal_alignment] || TextAlignments.leading,
|
|
287
|
+
"font-size": `${Math.max(10, font_size - 2)}px`,
|
|
288
|
+
"font-family": resolveFontFamily(font_name),
|
|
289
|
+
}),
|
|
290
|
+
);
|
|
291
|
+
|
|
198
292
|
const type = $derived.by((): HTMLInputAttributes["type"] => {
|
|
293
|
+
// Password masking must always win; a format constraint only changes the
|
|
294
|
+
// type of otherwise plain inputs.
|
|
295
|
+
if (props.keyboard_type !== "password") {
|
|
296
|
+
if (validation?.format === "email") {
|
|
297
|
+
return "email";
|
|
298
|
+
}
|
|
299
|
+
if (validation?.format === "uri") {
|
|
300
|
+
return "url";
|
|
301
|
+
}
|
|
302
|
+
}
|
|
199
303
|
switch (props.keyboard_type) {
|
|
200
304
|
case "decimal":
|
|
201
305
|
case "numeric":
|
|
@@ -219,34 +323,148 @@
|
|
|
219
323
|
keyboard_type === "password" ? "text" : keyboard_type,
|
|
220
324
|
);
|
|
221
325
|
|
|
326
|
+
// `pattern` only applies to text-like types, so number inputs skip it.
|
|
327
|
+
const compiledPattern = $derived(
|
|
328
|
+
type === "number" ? null : compilePattern(validation?.pattern),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// Only the constraints the resolved type honours are rendered: browsers
|
|
332
|
+
// ignore length/pattern on `type="number"` and min/max on text-like types.
|
|
333
|
+
// `step="any"` lifts the implicit `step=1` for the `decimal` keyboard, which
|
|
334
|
+
// would otherwise flag decimals as `stepMismatch`; `numeric` keeps it so the
|
|
335
|
+
// integer keypad and non-integer values agree. Patterns the browser would
|
|
336
|
+
// reject are enforced in `readValidity` instead of being rendered.
|
|
337
|
+
const constraintAttributes = $derived.by(
|
|
338
|
+
(): Pick<
|
|
339
|
+
HTMLInputAttributes,
|
|
340
|
+
"min" | "max" | "step" | "minlength" | "maxlength" | "pattern"
|
|
341
|
+
> =>
|
|
342
|
+
type === "number"
|
|
343
|
+
? {
|
|
344
|
+
min: validation?.minimum ?? undefined,
|
|
345
|
+
max: validation?.maximum ?? undefined,
|
|
346
|
+
step: keyboard_type === "decimal" ? "any" : undefined,
|
|
347
|
+
}
|
|
348
|
+
: {
|
|
349
|
+
minlength: validation?.minLength ?? undefined,
|
|
350
|
+
maxlength: validation?.maxLength ?? undefined,
|
|
351
|
+
pattern:
|
|
352
|
+
compiledPattern?.mode === "native"
|
|
353
|
+
? (validation?.pattern ?? undefined)
|
|
354
|
+
: undefined,
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
|
|
222
358
|
const isVisible = $derived(visible !== false);
|
|
223
359
|
|
|
360
|
+
$effect(() => {
|
|
361
|
+
// The `{#if isVisible}` block remounts the `<input>` empty, so the state
|
|
362
|
+
// that mirrors it must not survive a hide/show cycle either.
|
|
363
|
+
if (!isVisible) {
|
|
364
|
+
error = false;
|
|
365
|
+
failedRule = null;
|
|
366
|
+
valid = !required;
|
|
367
|
+
currentValue = "";
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// An <input> never shrinks below its browser-default intrinsic width, so in
|
|
372
|
+
// `fit` width mode a hidden sizer span mirrors the displayed text (styled
|
|
373
|
+
// and padded like the input) to give the wrapper its content size, and the
|
|
374
|
+
// input is stretched over it. This matches the editor preview.
|
|
375
|
+
const isContentSized = $derived(size.width.type === "fit");
|
|
376
|
+
const displayedText = $derived(
|
|
377
|
+
type === "password" ? "•".repeat(currentValue.length) : currentValue,
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
const sizerText = $derived(displayedText || placeholder || "\u200b");
|
|
381
|
+
const sizerStyle = $derived.by(() => {
|
|
382
|
+
const showsPlaceholder = displayedText === "";
|
|
383
|
+
return css({
|
|
384
|
+
padding: mapSpacing(padding),
|
|
385
|
+
"font-weight":
|
|
386
|
+
(showsPlaceholder ? placeholder_font_weight_int : font_weight_int) ??
|
|
387
|
+
FontWeights.regular,
|
|
388
|
+
"font-size": `${showsPlaceholder ? placeholder_font_size : font_size}px`,
|
|
389
|
+
"font-family": resolveFontFamily(
|
|
390
|
+
showsPlaceholder ? placeholder_font_name : font_name,
|
|
391
|
+
),
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
224
395
|
if (validationContext) {
|
|
225
396
|
$effect(() => {
|
|
226
397
|
// A hidden field can't be filled in, so it shouldn't be able to block
|
|
227
398
|
// Continue for the rest of the screen.
|
|
228
|
-
validationContext.updateSatisfaction(!isVisible || valid);
|
|
399
|
+
validationContext.updateSatisfaction(props.id, !isVisible || valid);
|
|
229
400
|
});
|
|
401
|
+
onDestroy(() => validationContext.removeInput(props.id));
|
|
230
402
|
}
|
|
231
403
|
</script>
|
|
232
404
|
|
|
233
405
|
{#if isVisible}
|
|
234
|
-
<div class="rc-
|
|
235
|
-
<
|
|
236
|
-
|
|
237
|
-
{
|
|
238
|
-
{
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
{
|
|
245
|
-
|
|
406
|
+
<div class="rc-input-text" style={containerStyle}>
|
|
407
|
+
<div
|
|
408
|
+
class="rc-gradient-border"
|
|
409
|
+
class:content-sized={isContentSized}
|
|
410
|
+
style={wrapperStyle}
|
|
411
|
+
>
|
|
412
|
+
{#if isContentSized}
|
|
413
|
+
<span class="sizer" aria-hidden="true" style={sizerStyle}
|
|
414
|
+
>{sizerText}</span
|
|
415
|
+
>
|
|
416
|
+
{/if}
|
|
417
|
+
<input
|
|
418
|
+
{type}
|
|
419
|
+
{placeholder}
|
|
420
|
+
{required}
|
|
421
|
+
{...constraintAttributes}
|
|
422
|
+
inputmode={inputMode}
|
|
423
|
+
autocapitalize={capitalize}
|
|
424
|
+
aria-invalid={error}
|
|
425
|
+
aria-describedby={showErrorMessage ? errorId : undefined}
|
|
426
|
+
style={inputStyle}
|
|
427
|
+
{oninput}
|
|
428
|
+
onfocus={() => (focused = true)}
|
|
429
|
+
{onblur}
|
|
430
|
+
/>
|
|
431
|
+
</div>
|
|
432
|
+
{#if hasErrorCopy}
|
|
433
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
434
|
+
<div class="rc-input-error" style={errorStyle} onclick={onErrorClick}>
|
|
435
|
+
{#if showErrorMessage}
|
|
436
|
+
<span class="rc-input-error-message" id={errorId} role="alert">
|
|
437
|
+
{@html errorHtml}
|
|
438
|
+
</span>
|
|
439
|
+
{/if}
|
|
440
|
+
</div>
|
|
441
|
+
{/if}
|
|
246
442
|
</div>
|
|
247
443
|
{/if}
|
|
248
444
|
|
|
249
445
|
<style>
|
|
446
|
+
.rc-input-error {
|
|
447
|
+
/* A parent stack paints a full-size absolutely positioned `::before`
|
|
448
|
+
overlay, which sits above static descendants and swallows their pointer
|
|
449
|
+
events. Positioning the row paints it after that overlay so links in the
|
|
450
|
+
message stay clickable. */
|
|
451
|
+
position: relative;
|
|
452
|
+
/* Reserve one line (line-height + top gap) so the message never shifts the
|
|
453
|
+
layout below the input when it appears. `em` follows the row's own
|
|
454
|
+
font-size from the inline style. */
|
|
455
|
+
flex: none;
|
|
456
|
+
box-sizing: border-box;
|
|
457
|
+
padding-top: 4px;
|
|
458
|
+
line-height: 1.4;
|
|
459
|
+
min-height: calc(1.4em + 4px);
|
|
460
|
+
/* Never let the message dictate the width: a `fit` width container sizes
|
|
461
|
+
to the bordered box (the sizer), and the row stretches to match and
|
|
462
|
+
wraps. A cyclic percentage min-width counts as zero for intrinsic
|
|
463
|
+
sizing, which is what keeps the row out of the measurement. */
|
|
464
|
+
width: 0;
|
|
465
|
+
min-width: 100%;
|
|
466
|
+
}
|
|
467
|
+
|
|
250
468
|
input {
|
|
251
469
|
width: 100%;
|
|
252
470
|
height: 100%;
|
|
@@ -255,6 +473,37 @@
|
|
|
255
473
|
border-radius: inherit;
|
|
256
474
|
}
|
|
257
475
|
|
|
476
|
+
.content-sized {
|
|
477
|
+
position: relative;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
.sizer {
|
|
481
|
+
display: block;
|
|
482
|
+
visibility: hidden;
|
|
483
|
+
white-space: pre;
|
|
484
|
+
line-height: normal; /* Match the intrinsic height of a bare <input> */
|
|
485
|
+
border-inline-end: 2px solid transparent; /* Room for the caret */
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
.content-sized input {
|
|
489
|
+
position: absolute;
|
|
490
|
+
inset: 0;
|
|
491
|
+
width: auto;
|
|
492
|
+
height: auto;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/* Spin buttons would overlap the text of a content-sized number input. */
|
|
496
|
+
.content-sized input::-webkit-outer-spin-button,
|
|
497
|
+
.content-sized input::-webkit-inner-spin-button {
|
|
498
|
+
-webkit-appearance: none;
|
|
499
|
+
margin: 0;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
.content-sized input[type="number"] {
|
|
503
|
+
-moz-appearance: textfield;
|
|
504
|
+
appearance: textfield;
|
|
505
|
+
}
|
|
506
|
+
|
|
258
507
|
input:focus-visible {
|
|
259
508
|
outline: none;
|
|
260
509
|
}
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
InputTextProps,
|
|
15
15
|
ReservedAttribute,
|
|
16
16
|
} from "../../types/components/input-text";
|
|
17
|
+
import type { Localizations } from "../../types/localization";
|
|
18
|
+
import type { ComponentInteractionData } from "../../types/paywall-component-interaction";
|
|
17
19
|
|
|
18
20
|
interface Props extends InputTextProps {
|
|
19
21
|
onInputChanged?: (fieldId: string, value: string) => void;
|
|
@@ -23,6 +25,10 @@
|
|
|
23
25
|
value: string,
|
|
24
26
|
) => void;
|
|
25
27
|
onSatisfactionChange?: (satisfied: boolean) => void;
|
|
28
|
+
onNavigateToUrl?: (url: string) => void;
|
|
29
|
+
onComponentInteraction?: (data: ComponentInteractionData) => void;
|
|
30
|
+
localizations?: Localizations;
|
|
31
|
+
selectedLocale?: string;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
const {
|
|
@@ -30,6 +36,10 @@
|
|
|
30
36
|
onSensitiveInputChanged,
|
|
31
37
|
onReservedAttributeChanged,
|
|
32
38
|
onSatisfactionChange,
|
|
39
|
+
onNavigateToUrl,
|
|
40
|
+
onComponentInteraction,
|
|
41
|
+
localizations = { en_US: {} },
|
|
42
|
+
selectedLocale,
|
|
33
43
|
...inputTextProps
|
|
34
44
|
}: Props = $props();
|
|
35
45
|
|
|
@@ -42,9 +52,8 @@
|
|
|
42
52
|
setColorModeContext();
|
|
43
53
|
setLocalizationContext(() => ({
|
|
44
54
|
defaultLocale: "en_US",
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
},
|
|
55
|
+
selectedLocale,
|
|
56
|
+
localizations,
|
|
48
57
|
}));
|
|
49
58
|
|
|
50
59
|
setPaywallContext({
|
|
@@ -53,7 +62,8 @@
|
|
|
53
62
|
baseVariables: readable(undefined),
|
|
54
63
|
infoPerPackage: readable(undefined),
|
|
55
64
|
onPurchase: () => {},
|
|
56
|
-
emitComponentInteraction: () => {},
|
|
65
|
+
emitComponentInteraction: onComponentInteraction ?? (() => {}),
|
|
66
|
+
onNavigateToUrl,
|
|
57
67
|
onButtonAction: () => {},
|
|
58
68
|
onInputChanged,
|
|
59
69
|
onSensitiveInputChanged,
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import type { InputTextProps, ReservedAttribute } from "../../types/components/input-text";
|
|
2
|
+
import type { Localizations } from "../../types/localization";
|
|
3
|
+
import type { ComponentInteractionData } from "../../types/paywall-component-interaction";
|
|
2
4
|
interface Props extends InputTextProps {
|
|
3
5
|
onInputChanged?: (fieldId: string, value: string) => void;
|
|
4
6
|
onSensitiveInputChanged?: (fieldId: string, value: string) => void;
|
|
5
7
|
onReservedAttributeChanged?: (reservedAttribute: ReservedAttribute, value: string) => void;
|
|
6
8
|
onSatisfactionChange?: (satisfied: boolean) => void;
|
|
9
|
+
onNavigateToUrl?: (url: string) => void;
|
|
10
|
+
onComponentInteraction?: (data: ComponentInteractionData) => void;
|
|
11
|
+
localizations?: Localizations;
|
|
12
|
+
selectedLocale?: string;
|
|
7
13
|
}
|
|
8
14
|
declare const InputTextTestWrapper: import("svelte").Component<Props, {}, "">;
|
|
9
15
|
type InputTextTestWrapper = ReturnType<typeof InputTextTestWrapper>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a `validation.pattern` is enforced on the rendered input.
|
|
3
|
+
*
|
|
4
|
+
* Browsers compile the `pattern` attribute as `^(?:pattern)$` with the `v`
|
|
5
|
+
* flag and silently ignore it when that fails, which would drop the constraint
|
|
6
|
+
* altogether. Patterns that are valid `v`-mode regexes are left to the browser
|
|
7
|
+
* (`patternMismatch`); anything else that still compiles as a plain JS regex is
|
|
8
|
+
* matched by the component and reported through `setCustomValidity`.
|
|
9
|
+
*/
|
|
10
|
+
export type CompiledPattern = {
|
|
11
|
+
mode: "native";
|
|
12
|
+
} | {
|
|
13
|
+
mode: "custom";
|
|
14
|
+
regex: RegExp;
|
|
15
|
+
};
|
|
16
|
+
export declare function compilePattern(pattern: string | null | undefined): CompiledPattern | null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const FALLBACK_FLAGS = ["u", ""];
|
|
2
|
+
export function compilePattern(pattern) {
|
|
3
|
+
if (!pattern) {
|
|
4
|
+
return null;
|
|
5
|
+
}
|
|
6
|
+
const source = `^(?:${pattern})$`;
|
|
7
|
+
try {
|
|
8
|
+
new RegExp(source, "v");
|
|
9
|
+
return { mode: "native" };
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// Not a valid `v`-mode regex; try the more lenient dialects below.
|
|
13
|
+
}
|
|
14
|
+
for (const flags of FALLBACK_FLAGS) {
|
|
15
|
+
try {
|
|
16
|
+
return { mode: "custom", regex: new RegExp(source, flags) };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// Try the next dialect.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
console.warn(`Ignoring invalid input_text validation pattern: ${pattern}`);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { InputTextValidationRule } from "../../types/components/input-text";
|
|
2
|
+
/**
|
|
3
|
+
* Maps a native `ValidityState` to the first failing validation rule, so the
|
|
4
|
+
* per-rule error copy can be picked. Order matters: an empty required field is
|
|
5
|
+
* reported as `required` even if other constraints would also fail.
|
|
6
|
+
*
|
|
7
|
+
* `typeMismatch` lands in `format` even without `validation.format`, since a
|
|
8
|
+
* `keyboard_type: "email"` input is `type="email"` and fails the same way.
|
|
9
|
+
* `stepMismatch` (a non-integer on the `numeric` keyboard) falls back to
|
|
10
|
+
* `format` as well.
|
|
11
|
+
*
|
|
12
|
+
* `customError` is only ever set by the component for a `pattern` the browser
|
|
13
|
+
* cannot compile itself, so it is reported as `pattern`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function failingRuleFromValidity(validity: ValidityState): InputTextValidationRule | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps a native `ValidityState` to the first failing validation rule, so the
|
|
3
|
+
* per-rule error copy can be picked. Order matters: an empty required field is
|
|
4
|
+
* reported as `required` even if other constraints would also fail.
|
|
5
|
+
*
|
|
6
|
+
* `typeMismatch` lands in `format` even without `validation.format`, since a
|
|
7
|
+
* `keyboard_type: "email"` input is `type="email"` and fails the same way.
|
|
8
|
+
* `stepMismatch` (a non-integer on the `numeric` keyboard) falls back to
|
|
9
|
+
* `format` as well.
|
|
10
|
+
*
|
|
11
|
+
* `customError` is only ever set by the component for a `pattern` the browser
|
|
12
|
+
* cannot compile itself, so it is reported as `pattern`.
|
|
13
|
+
*/
|
|
14
|
+
export function failingRuleFromValidity(validity) {
|
|
15
|
+
if (validity.valid) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if (validity.valueMissing) {
|
|
19
|
+
return "required";
|
|
20
|
+
}
|
|
21
|
+
if (validity.rangeUnderflow) {
|
|
22
|
+
return "minimum";
|
|
23
|
+
}
|
|
24
|
+
if (validity.rangeOverflow) {
|
|
25
|
+
return "maximum";
|
|
26
|
+
}
|
|
27
|
+
if (validity.tooShort) {
|
|
28
|
+
return "minLength";
|
|
29
|
+
}
|
|
30
|
+
if (validity.tooLong) {
|
|
31
|
+
return "maxLength";
|
|
32
|
+
}
|
|
33
|
+
if (validity.patternMismatch || validity.customError) {
|
|
34
|
+
return "pattern";
|
|
35
|
+
}
|
|
36
|
+
if (validity.typeMismatch || validity.badInput || validity.stepMismatch) {
|
|
37
|
+
return "format";
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
@@ -44,10 +44,13 @@
|
|
|
44
44
|
if (validationContext) {
|
|
45
45
|
const unsubscribe = inputChoiceContext.isSatisfied.subscribe(
|
|
46
46
|
(satisfied) => {
|
|
47
|
-
validationContext.updateSatisfaction(satisfied);
|
|
47
|
+
validationContext.updateSatisfaction(props.id, satisfied);
|
|
48
48
|
},
|
|
49
49
|
);
|
|
50
|
-
onDestroy(
|
|
50
|
+
onDestroy(() => {
|
|
51
|
+
unsubscribe();
|
|
52
|
+
validationContext.removeInput(props.id);
|
|
53
|
+
});
|
|
51
54
|
}
|
|
52
55
|
</script>
|
|
53
56
|
|
|
@@ -30,10 +30,13 @@
|
|
|
30
30
|
if (validationContext) {
|
|
31
31
|
const unsubscribe = inputChoiceContext.isSatisfied.subscribe(
|
|
32
32
|
(satisfied) => {
|
|
33
|
-
validationContext.updateSatisfaction(satisfied);
|
|
33
|
+
validationContext.updateSatisfaction(props.id, satisfied);
|
|
34
34
|
},
|
|
35
35
|
);
|
|
36
|
-
onDestroy(
|
|
36
|
+
onDestroy(() => {
|
|
37
|
+
unsubscribe();
|
|
38
|
+
validationContext.removeInput(props.id);
|
|
39
|
+
});
|
|
37
40
|
}
|
|
38
41
|
</script>
|
|
39
42
|
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { BACKGROUND_PAYWALL } from "./fixtures/background-paywall";
|
|
28
28
|
import { FIXED_HEIGHT_STACK_PAYWALL } from "./fixtures/fixed-height-stack-paywall";
|
|
29
29
|
import { FILL_HEIGHT_WEBVIEW_PAYWALL } from "./fixtures/fill-height-webview-paywall";
|
|
30
|
+
import { createHeroMediaPaywall } from "./fixtures/hero-media-paywall";
|
|
30
31
|
import { OVERRIDE_PAYWALL } from "./fixtures/override-paywall";
|
|
31
32
|
import { SHEET_PAYWALL } from "./fixtures/sheet-paywall";
|
|
32
33
|
import { SHEET_PAYWALL_VIDEO_STACKING } from "./fixtures/sheet-video-stacking-paywall";
|
|
@@ -565,6 +566,31 @@
|
|
|
565
566
|
paywallData: paywallWithTransparentHeaderAndTopImage,
|
|
566
567
|
}}
|
|
567
568
|
/>
|
|
569
|
+
|
|
570
|
+
<Story
|
|
571
|
+
name="Hero media - nested image"
|
|
572
|
+
decorators={[viewportDecorator(375, 500, 0)]}
|
|
573
|
+
args={{
|
|
574
|
+
paywallData: createHeroMediaPaywall("image"),
|
|
575
|
+
}}
|
|
576
|
+
/>
|
|
577
|
+
|
|
578
|
+
<Story
|
|
579
|
+
name="Hero media - nested video"
|
|
580
|
+
decorators={[viewportDecorator(375, 500, 0)]}
|
|
581
|
+
args={{
|
|
582
|
+
paywallData: createHeroMediaPaywall("video"),
|
|
583
|
+
}}
|
|
584
|
+
/>
|
|
585
|
+
|
|
586
|
+
<Story
|
|
587
|
+
name="Hero media - nested web view"
|
|
588
|
+
decorators={[viewportDecorator(375, 500, 0)]}
|
|
589
|
+
args={{
|
|
590
|
+
paywallData: createHeroMediaPaywall("web_view"),
|
|
591
|
+
}}
|
|
592
|
+
/>
|
|
593
|
+
|
|
568
594
|
<Story
|
|
569
595
|
name="Timeline"
|
|
570
596
|
args={{
|
|
@@ -50,7 +50,10 @@
|
|
|
50
50
|
import { onMount } from "svelte";
|
|
51
51
|
import { derived, readable, writable } from "svelte/store";
|
|
52
52
|
import Stack from "../stack/Stack.svelte";
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
firstContentIsHeroMedia,
|
|
55
|
+
mapPaywallContentStackStyle,
|
|
56
|
+
} from "./paywall-utils";
|
|
54
57
|
import Sheet from "./Sheet.svelte";
|
|
55
58
|
import ViewportBackdrop from "./ViewportBackdrop.svelte";
|
|
56
59
|
import {
|
|
@@ -425,11 +428,8 @@
|
|
|
425
428
|
|
|
426
429
|
const { stack, sticky_footer, header } = base;
|
|
427
430
|
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
firstComponent?.type === "image" &&
|
|
431
|
-
firstComponent.size.width.type === "fill";
|
|
432
|
-
const pullContentUnderHeader = !!header && firstComponentIsFullWidthImage;
|
|
431
|
+
const pullContentUnderHeader =
|
|
432
|
+
!!header && firstContentIsHeroMedia(stack.components);
|
|
433
433
|
|
|
434
434
|
let headerHeight = $state(0);
|
|
435
435
|
let footerHeight = $state(0);
|
|
@@ -496,6 +496,12 @@
|
|
|
496
496
|
display: flex;
|
|
497
497
|
flex-direction: column;
|
|
498
498
|
align-items: stretch;
|
|
499
|
+
box-sizing: border-box;
|
|
500
|
+
/* Definite containing block so fill-width children (`-webkit-fill-available`)
|
|
501
|
+
do not shrink-wrap the host overlay and walk a Chromium zoom feedback loop. */
|
|
502
|
+
width: 100%;
|
|
503
|
+
max-width: 100%;
|
|
504
|
+
min-width: 0;
|
|
499
505
|
height: 100%;
|
|
500
506
|
|
|
501
507
|
transition-property: filter, transform;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { createStack, createTextComponent } from "./helpers";
|
|
2
|
+
const ZERO_SPACING = {
|
|
3
|
+
bottom: 0,
|
|
4
|
+
leading: 0,
|
|
5
|
+
top: 0,
|
|
6
|
+
trailing: 0,
|
|
7
|
+
};
|
|
8
|
+
const imageSource = (url) => ({
|
|
9
|
+
light: {
|
|
10
|
+
width: 640,
|
|
11
|
+
height: 360,
|
|
12
|
+
original: url,
|
|
13
|
+
heic: url,
|
|
14
|
+
heic_low_res: url,
|
|
15
|
+
webp: url,
|
|
16
|
+
webp_low_res: url,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
const createImageHero = () => ({
|
|
20
|
+
type: "image",
|
|
21
|
+
id: "image-hero",
|
|
22
|
+
name: "Image hero",
|
|
23
|
+
source: imageSource("https://placehold.co/640x360/2563eb/ffffff.webp?text=Image+Hero"),
|
|
24
|
+
size: {
|
|
25
|
+
width: { type: "fill" },
|
|
26
|
+
height: { type: "fixed", value: 240 },
|
|
27
|
+
},
|
|
28
|
+
mask_shape: null,
|
|
29
|
+
fit_mode: "fill",
|
|
30
|
+
padding: { ...ZERO_SPACING },
|
|
31
|
+
margin: { ...ZERO_SPACING },
|
|
32
|
+
color_overlay: null,
|
|
33
|
+
border: null,
|
|
34
|
+
shadow: null,
|
|
35
|
+
});
|
|
36
|
+
const createVideoHero = () => ({
|
|
37
|
+
type: "video",
|
|
38
|
+
id: "video-hero",
|
|
39
|
+
name: "Video hero",
|
|
40
|
+
source: null,
|
|
41
|
+
fallback_source: imageSource("https://placehold.co/640x360/7c3aed/ffffff.webp?text=Video+Hero"),
|
|
42
|
+
size: {
|
|
43
|
+
width: { type: "fill" },
|
|
44
|
+
height: { type: "fixed", value: 240 },
|
|
45
|
+
},
|
|
46
|
+
mask_shape: null,
|
|
47
|
+
fit_mode: "fill",
|
|
48
|
+
padding: { ...ZERO_SPACING },
|
|
49
|
+
margin: { ...ZERO_SPACING },
|
|
50
|
+
color_overlay: null,
|
|
51
|
+
border: null,
|
|
52
|
+
shadow: null,
|
|
53
|
+
auto_play: false,
|
|
54
|
+
loop: false,
|
|
55
|
+
mute_audio: true,
|
|
56
|
+
show_controls: false,
|
|
57
|
+
});
|
|
58
|
+
const createWebViewHero = () => ({
|
|
59
|
+
type: "web_view",
|
|
60
|
+
id: "web-view-hero",
|
|
61
|
+
name: "Web view hero",
|
|
62
|
+
protocol_version: 1,
|
|
63
|
+
url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/2705.svg",
|
|
64
|
+
size: {
|
|
65
|
+
width: { type: "fill" },
|
|
66
|
+
height: { type: "fixed", value: 240 },
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const createHero = (type) => {
|
|
70
|
+
switch (type) {
|
|
71
|
+
case "image":
|
|
72
|
+
return createImageHero();
|
|
73
|
+
case "video":
|
|
74
|
+
return createVideoHero();
|
|
75
|
+
case "web_view":
|
|
76
|
+
return createWebViewHero();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const createHeader = () => {
|
|
80
|
+
const stack = createStack({
|
|
81
|
+
id: "hero-header-stack",
|
|
82
|
+
name: "Hero header stack",
|
|
83
|
+
components: [
|
|
84
|
+
{
|
|
85
|
+
...createTextComponent({
|
|
86
|
+
id: "hero-header-label",
|
|
87
|
+
textLid: "hero_header_label",
|
|
88
|
+
}),
|
|
89
|
+
color: {
|
|
90
|
+
light: {
|
|
91
|
+
type: "hex",
|
|
92
|
+
value: "#ffffff",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
font_size: 18,
|
|
96
|
+
font_weight: "bold",
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
stack.size.width = { type: "fill" };
|
|
101
|
+
stack.padding = {
|
|
102
|
+
bottom: 18,
|
|
103
|
+
leading: 20,
|
|
104
|
+
top: 18,
|
|
105
|
+
trailing: 20,
|
|
106
|
+
};
|
|
107
|
+
stack.background_color = {
|
|
108
|
+
light: {
|
|
109
|
+
type: "hex",
|
|
110
|
+
value: "#11182799",
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
type: "header",
|
|
115
|
+
id: "hero-header",
|
|
116
|
+
name: "Hero header",
|
|
117
|
+
stack,
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
export const createHeroMediaPaywall = (type) => {
|
|
121
|
+
const heroContainer = createStack({
|
|
122
|
+
id: `${type}-hero-container`,
|
|
123
|
+
name: `${type} hero container`,
|
|
124
|
+
components: [createHero(type)],
|
|
125
|
+
});
|
|
126
|
+
heroContainer.size.width = { type: "fill" };
|
|
127
|
+
heroContainer.padding = { ...ZERO_SPACING };
|
|
128
|
+
heroContainer.spacing = 0;
|
|
129
|
+
const rootStack = createStack({
|
|
130
|
+
id: `${type}-hero-root`,
|
|
131
|
+
name: `${type} hero root`,
|
|
132
|
+
components: [
|
|
133
|
+
heroContainer,
|
|
134
|
+
{
|
|
135
|
+
...createTextComponent({
|
|
136
|
+
id: `${type}-body`,
|
|
137
|
+
textLid: "body",
|
|
138
|
+
}),
|
|
139
|
+
font_size: 18,
|
|
140
|
+
horizontal_alignment: "center",
|
|
141
|
+
size: {
|
|
142
|
+
width: { type: "fill" },
|
|
143
|
+
height: { type: "fit" },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
rootStack.size.width = { type: "fill" };
|
|
149
|
+
rootStack.padding = {
|
|
150
|
+
bottom: 24,
|
|
151
|
+
leading: 0,
|
|
152
|
+
top: 0,
|
|
153
|
+
trailing: 0,
|
|
154
|
+
};
|
|
155
|
+
rootStack.spacing = 24;
|
|
156
|
+
return {
|
|
157
|
+
id: `${type}_hero_paywall`,
|
|
158
|
+
default_locale: "en_US",
|
|
159
|
+
components_localizations: {
|
|
160
|
+
en_US: {
|
|
161
|
+
hero_header_label: "Header overlays the hero",
|
|
162
|
+
body: `Full-width ${type.replace("_", " ")} detected through a nested stack`,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
components_config: {
|
|
166
|
+
base: {
|
|
167
|
+
background: {
|
|
168
|
+
type: "color",
|
|
169
|
+
value: {
|
|
170
|
+
light: {
|
|
171
|
+
type: "hex",
|
|
172
|
+
value: "#ffffff",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
header: createHeader(),
|
|
177
|
+
stack: rootStack,
|
|
178
|
+
sticky_footer: null,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import type { Size } from "../../types";
|
|
2
|
+
import type { Component } from "../../types/component";
|
|
2
3
|
import type { CSS } from "../../utils/base-utils";
|
|
4
|
+
export declare function firstContentIsHeroMedia(components: Component[]): boolean;
|
|
3
5
|
export declare function mapPaywallContentStackStyle(height: Size, pullContentUnderHeader: boolean, headerHeight: number): CSS;
|
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
function findFirstContent(components) {
|
|
2
|
+
for (const component of components) {
|
|
3
|
+
if (component.type === "fallback_header") {
|
|
4
|
+
continue;
|
|
5
|
+
}
|
|
6
|
+
if (component.type === "stack") {
|
|
7
|
+
const nestedContent = findFirstContent(component.components);
|
|
8
|
+
if (nestedContent) {
|
|
9
|
+
return nestedContent;
|
|
10
|
+
}
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
return component;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function firstContentIsHeroMedia(components) {
|
|
17
|
+
const firstContent = findFirstContent(components);
|
|
18
|
+
return ((firstContent?.type === "image" ||
|
|
19
|
+
firstContent?.type === "video" ||
|
|
20
|
+
firstContent?.type === "web_view") &&
|
|
21
|
+
firstContent.size.width.type === "fill");
|
|
22
|
+
}
|
|
1
23
|
export function mapPaywallContentStackStyle(height, pullContentUnderHeader, headerHeight) {
|
|
2
24
|
return {
|
|
3
25
|
// Nested fill children need a definite root height; other roots keep
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
getHtmlFromMarkdown,
|
|
4
4
|
getTextComponentStyles,
|
|
5
5
|
getTextWrapperInlineStyles,
|
|
6
|
+
markdownLinkUrlFromClick,
|
|
6
7
|
} from "./text-utils";
|
|
7
8
|
import Text from "./Text.svelte";
|
|
8
9
|
import { getColorModeContext } from "../../stores/color-mode";
|
|
@@ -53,28 +54,7 @@
|
|
|
53
54
|
const markdownParsed = $derived(getHtmlFromMarkdown(label));
|
|
54
55
|
|
|
55
56
|
const onclick = (event: MouseEvent) => {
|
|
56
|
-
|
|
57
|
-
event.defaultPrevented ||
|
|
58
|
-
event.button !== 0 ||
|
|
59
|
-
event.metaKey ||
|
|
60
|
-
event.ctrlKey ||
|
|
61
|
-
event.shiftKey ||
|
|
62
|
-
event.altKey
|
|
63
|
-
) {
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const target = event.target;
|
|
68
|
-
if (!(target instanceof Element)) {
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const anchor = target.closest("a[href]");
|
|
73
|
-
if (!(anchor instanceof HTMLAnchorElement)) {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const url = anchor.getAttribute("href") ?? anchor.href;
|
|
57
|
+
const url = markdownLinkUrlFromClick(event);
|
|
78
58
|
if (!url) {
|
|
79
59
|
return;
|
|
80
60
|
}
|
|
@@ -4,6 +4,14 @@ import type { TextNodeProps } from "../../types/components/text";
|
|
|
4
4
|
import type { AppFontsConfig } from "../../types/ui-config";
|
|
5
5
|
import { type CSS } from "../../utils/base-utils";
|
|
6
6
|
export declare const defaultColor: ColorScheme;
|
|
7
|
+
/**
|
|
8
|
+
* Returns the `href` of the markdown link a plain left click landed on, or
|
|
9
|
+
* `null` when the click should be left alone (modifier keys, non-primary
|
|
10
|
+
* button, already handled, not on a link). Hosts route these through
|
|
11
|
+
* `onNavigateToUrl`, since a bare `target="_blank"` anchor is blocked in some
|
|
12
|
+
* of them (embedded webviews, sandboxed frames).
|
|
13
|
+
*/
|
|
14
|
+
export declare function markdownLinkUrlFromClick(event: MouseEvent): string | null;
|
|
7
15
|
export declare function mapTextColor(colorMode: ColorMode, scheme: ColorGradientScheme): CSS;
|
|
8
16
|
/**
|
|
9
17
|
* Generates comprehensive styles for text components by combining text, component and size styles
|
|
@@ -6,6 +6,32 @@ import { getScopedFontFamily, isFontRCFMManaged } from "../../utils/font-utils";
|
|
|
6
6
|
export const defaultColor = {
|
|
7
7
|
light: { type: "hex", value: DEFAULT_TEXT_COLOR },
|
|
8
8
|
};
|
|
9
|
+
/**
|
|
10
|
+
* Returns the `href` of the markdown link a plain left click landed on, or
|
|
11
|
+
* `null` when the click should be left alone (modifier keys, non-primary
|
|
12
|
+
* button, already handled, not on a link). Hosts route these through
|
|
13
|
+
* `onNavigateToUrl`, since a bare `target="_blank"` anchor is blocked in some
|
|
14
|
+
* of them (embedded webviews, sandboxed frames).
|
|
15
|
+
*/
|
|
16
|
+
export function markdownLinkUrlFromClick(event) {
|
|
17
|
+
if (event.defaultPrevented ||
|
|
18
|
+
event.button !== 0 ||
|
|
19
|
+
event.metaKey ||
|
|
20
|
+
event.ctrlKey ||
|
|
21
|
+
event.shiftKey ||
|
|
22
|
+
event.altKey) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const target = event.target;
|
|
26
|
+
if (!(target instanceof Element)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const anchor = target.closest("a[href]");
|
|
30
|
+
if (!(anchor instanceof HTMLAnchorElement)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return anchor.getAttribute("href") || anchor.href || null;
|
|
34
|
+
}
|
|
9
35
|
export function mapTextColor(colorMode, scheme) {
|
|
10
36
|
const info = mapColorMode(colorMode, scheme);
|
|
11
37
|
const color = mapColorInfo(info);
|
|
@@ -101,7 +101,11 @@
|
|
|
101
101
|
}: Props = $props();
|
|
102
102
|
</script>
|
|
103
103
|
|
|
104
|
-
<div
|
|
104
|
+
<div
|
|
105
|
+
id={containerId}
|
|
106
|
+
class="paywall-container"
|
|
107
|
+
style="width: 100%; min-width: 0; height: 100%;"
|
|
108
|
+
>
|
|
105
109
|
{#if paywallComponents}
|
|
106
110
|
<Paywall
|
|
107
111
|
paywallData={paywallComponents}
|
|
@@ -4,13 +4,16 @@ import { type Readable } from "svelte/store";
|
|
|
4
4
|
* Used to restore selection state when navigating back to a step.
|
|
5
5
|
*/
|
|
6
6
|
export type InitialInputSelections = Record<string, string[]>;
|
|
7
|
-
interface InputValidationContext {
|
|
7
|
+
export interface InputValidationContext {
|
|
8
|
+
/** True only when every registered input is satisfied. */
|
|
8
9
|
isSatisfied: Readable<boolean>;
|
|
9
|
-
|
|
10
|
+
/** Registers or updates the satisfaction of a single input, keyed by component id. */
|
|
11
|
+
updateSatisfaction: (id: string, satisfied: boolean) => void;
|
|
12
|
+
/** Stops an unmounted input from gating the screen. */
|
|
13
|
+
removeInput: (id: string) => void;
|
|
10
14
|
}
|
|
11
15
|
export declare function createInputValidationContext(initiallySatisfied?: boolean): InputValidationContext;
|
|
12
16
|
export declare function setInputValidationContext(context: InputValidationContext): void;
|
|
13
17
|
export declare function getInputValidationContext(): InputValidationContext | undefined;
|
|
14
18
|
export declare function setInitialInputSelectionsContext(selections: InitialInputSelections): void;
|
|
15
19
|
export declare function getInitialInputSelectionsContext(): InitialInputSelections;
|
|
16
|
-
export {};
|
|
@@ -1,15 +1,38 @@
|
|
|
1
1
|
import { getContext, setContext } from "svelte";
|
|
2
|
-
import { writable } from "svelte/store";
|
|
2
|
+
import { derived, writable } from "svelte/store";
|
|
3
3
|
const key = Symbol("inputValidation");
|
|
4
4
|
const initialSelectionsKey = Symbol("initialInputSelections");
|
|
5
5
|
export function createInputValidationContext(initiallySatisfied = true) {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
const satisfactionById = writable(new Map());
|
|
7
|
+
// `initiallySatisfied` only matters while no input has registered yet; once
|
|
8
|
+
// any input reports, the map is the single source of truth.
|
|
9
|
+
const isSatisfied = derived(satisfactionById, (byId) => byId.size === 0
|
|
10
|
+
? initiallySatisfied
|
|
11
|
+
: Array.from(byId.values()).every(Boolean));
|
|
12
|
+
const updateSatisfaction = (id, satisfied) => {
|
|
13
|
+
satisfactionById.update((byId) => {
|
|
14
|
+
if (byId.get(id) === satisfied) {
|
|
15
|
+
return byId;
|
|
16
|
+
}
|
|
17
|
+
const next = new Map(byId);
|
|
18
|
+
next.set(id, satisfied);
|
|
19
|
+
return next;
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
const removeInput = (id) => {
|
|
23
|
+
satisfactionById.update((byId) => {
|
|
24
|
+
if (!byId.has(id)) {
|
|
25
|
+
return byId;
|
|
26
|
+
}
|
|
27
|
+
const next = new Map(byId);
|
|
28
|
+
next.delete(id);
|
|
29
|
+
return next;
|
|
30
|
+
});
|
|
9
31
|
};
|
|
10
32
|
return {
|
|
11
33
|
isSatisfied,
|
|
12
34
|
updateSatisfaction,
|
|
35
|
+
removeInput,
|
|
13
36
|
};
|
|
14
37
|
}
|
|
15
38
|
export function setInputValidationContext(context) {
|
|
@@ -6,6 +6,22 @@ import type { Overrides } from "../overrides";
|
|
|
6
6
|
export type InputTextCapitalizeType = "none" | "sentences" | "words" | "characters";
|
|
7
7
|
export type InputTextKeyboardType = "decimal" | "email" | "numeric" | "password" | "tel" | "text" | "url";
|
|
8
8
|
export type ReservedAttribute = "$email" | "$displayName" | "$phoneNumber";
|
|
9
|
+
export type InputTextValidationFormat = "email" | "uri";
|
|
10
|
+
/**
|
|
11
|
+
* JSON Schema subset mapped to native HTML constraint attributes.
|
|
12
|
+
* Keys keep the JSON Schema camelCase vocabulary.
|
|
13
|
+
*/
|
|
14
|
+
export interface InputTextValidation {
|
|
15
|
+
minimum?: number | null;
|
|
16
|
+
maximum?: number | null;
|
|
17
|
+
minLength?: number | null;
|
|
18
|
+
maxLength?: number | null;
|
|
19
|
+
pattern?: string | null;
|
|
20
|
+
format?: InputTextValidationFormat | null;
|
|
21
|
+
}
|
|
22
|
+
export type InputTextValidationRule = "required" | "minimum" | "maximum" | "minLength" | "maxLength" | "pattern" | "format";
|
|
23
|
+
/** Per-rule error copy; `validation_error_lid` is the fallback for missing keys. */
|
|
24
|
+
export type InputTextValidationErrorLids = Partial<Record<InputTextValidationRule, string | null>>;
|
|
9
25
|
export interface InputTextProps extends BaseComponent {
|
|
10
26
|
type: "input_text";
|
|
11
27
|
visible?: boolean | null;
|
|
@@ -15,6 +31,9 @@ export interface InputTextProps extends BaseComponent {
|
|
|
15
31
|
field_id: string;
|
|
16
32
|
reserved_attribute?: ReservedAttribute | null;
|
|
17
33
|
required: boolean;
|
|
34
|
+
validation?: InputTextValidation | null;
|
|
35
|
+
validation_error_lid?: string | null;
|
|
36
|
+
validation_error_lids?: InputTextValidationErrorLids | null;
|
|
18
37
|
size: SizeType;
|
|
19
38
|
padding: Spacing;
|
|
20
39
|
margin: Spacing;
|
|
@@ -61,7 +61,7 @@ export type OverrideCondition = IntroOfferCondition | MultipleIntroOffersConditi
|
|
|
61
61
|
/**
|
|
62
62
|
* The reserved properties of an override that should not be included in the override properties
|
|
63
63
|
*/
|
|
64
|
-
type ReservedProperty = "id" | "type" | "overrides" | "stack" | "components" | "items" | "pages" | "tabs" | "field_id" | "option_id" | "keyboard_type" | "capitalize";
|
|
64
|
+
type ReservedProperty = "id" | "type" | "overrides" | "stack" | "components" | "items" | "pages" | "tabs" | "field_id" | "option_id" | "keyboard_type" | "capitalize" | "validation" | "validation_error_lid" | "validation_error_lids";
|
|
65
65
|
type OverrideProperties<C> = Partial<Omit<C, ReservedProperty>>;
|
|
66
66
|
type Override<C> = {
|
|
67
67
|
conditions: OverrideCondition[];
|
package/dist/types.d.ts
CHANGED
|
@@ -128,4 +128,4 @@ export declare enum StackDistribution {
|
|
|
128
128
|
export type { WorkflowScreen } from "./types/workflow";
|
|
129
129
|
export type { ColorScheme } from "./types/colors";
|
|
130
130
|
export type { ComponentInteractionData, ComponentInteractionType, OnComponentInteraction, } from "./types/paywall-component-interaction";
|
|
131
|
-
export type { ReservedAttribute } from "./types/components/input-text";
|
|
131
|
+
export type { InputTextValidation, InputTextValidationErrorLids, InputTextValidationFormat, InputTextValidationRule, ReservedAttribute, } from "./types/components/input-text";
|