@revenuecat/purchases-ui-js 4.8.21 → 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/carousel/Carousel.svelte +3 -6
- package/dist/components/carousel/carousel-utils.d.ts +6 -0
- package/dist/components/carousel/carousel-utils.js +8 -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 +40 -0
- package/dist/components/paywall/Paywall.svelte +14 -7
- package/dist/components/paywall/Sheet.svelte +19 -6
- package/dist/components/paywall/ViewportBackdrop.svelte +13 -1
- package/dist/components/paywall/ViewportBackdrop.svelte.d.ts +1 -0
- package/dist/components/paywall/fixtures/gradient-sheet-paywall.d.ts +2 -0
- package/dist/components/paywall/fixtures/gradient-sheet-paywall.js +68 -0
- 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/fixtures/sheet-close-button-paywall.d.ts +2 -0
- package/dist/components/paywall/fixtures/sheet-close-button-paywall.js +62 -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/components/workflows/Workflow.svelte +2 -1
- package/dist/stores/inputValidation.d.ts +6 -3
- package/dist/stores/inputValidation.js +27 -4
- package/dist/stores/state.js +2 -2
- 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/dist/utils/safe-area-background.js +1 -1
- package/dist/utils/style-utils.js +2 -2
- 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.
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { onMount } from "svelte";
|
|
25
25
|
import {
|
|
26
26
|
clamp,
|
|
27
|
+
getDefaultPageIndex,
|
|
27
28
|
getSwipeTargetPage,
|
|
28
29
|
getTranslation,
|
|
29
30
|
mapPageAlignment,
|
|
@@ -132,10 +133,6 @@
|
|
|
132
133
|
let pageWidth = $derived(carouselWidth - pageOffset);
|
|
133
134
|
let gestureStartPage = $state<number | null>(null);
|
|
134
135
|
|
|
135
|
-
function getDefaultPageIndex() {
|
|
136
|
-
return pages.at(initial_page_index) !== undefined ? initial_page_index : 0;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
136
|
function normalizePageIndex(index: number) {
|
|
140
137
|
if (pages.length === 0) {
|
|
141
138
|
return 0;
|
|
@@ -199,7 +196,7 @@
|
|
|
199
196
|
}
|
|
200
197
|
|
|
201
198
|
onMount(() => {
|
|
202
|
-
const initialPage = getDefaultPageIndex();
|
|
199
|
+
const initialPage = getDefaultPageIndex(pages, initial_page_index);
|
|
203
200
|
currentPage = initialPage;
|
|
204
201
|
|
|
205
202
|
stopTransition();
|
|
@@ -265,7 +262,7 @@
|
|
|
265
262
|
destinationIndex: destinationPage,
|
|
266
263
|
originContextName: getPageContextName(gestureStartPage),
|
|
267
264
|
destinationContextName: getPageContextName(destinationPage),
|
|
268
|
-
defaultIndex: getDefaultPageIndex(),
|
|
265
|
+
defaultIndex: getDefaultPageIndex(pages, initial_page_index),
|
|
269
266
|
});
|
|
270
267
|
}
|
|
271
268
|
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { VerticalAlignment } from "../../types/alignment";
|
|
2
2
|
export declare function mapPageAlignment(alignment: VerticalAlignment): string;
|
|
3
3
|
export declare function clamp(value: number, min: number, max: number): number;
|
|
4
|
+
/**
|
|
5
|
+
* Resolves the carousel's starting page. Out-of-range indexes (including a
|
|
6
|
+
* missing `pages[i]` hole) fall back to 0. Uses index access rather than
|
|
7
|
+
* `Array.prototype.at` so Chrome < 92 does not throw while mounting.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getDefaultPageIndex<T>(pages: readonly T[], initialPageIndex: number): number;
|
|
4
10
|
/**
|
|
5
11
|
* Advance one page when the drag passed this fraction of a page's width,
|
|
6
12
|
* matching the iOS SDK carousel. A shorter drag snaps back to the page the
|
|
@@ -11,6 +11,14 @@ export function mapPageAlignment(alignment) {
|
|
|
11
11
|
export function clamp(value, min, max) {
|
|
12
12
|
return Math.max(min, Math.min(value, max));
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the carousel's starting page. Out-of-range indexes (including a
|
|
16
|
+
* missing `pages[i]` hole) fall back to 0. Uses index access rather than
|
|
17
|
+
* `Array.prototype.at` so Chrome < 92 does not throw while mounting.
|
|
18
|
+
*/
|
|
19
|
+
export function getDefaultPageIndex(pages, initialPageIndex) {
|
|
20
|
+
return pages[initialPageIndex] !== undefined ? initialPageIndex : 0;
|
|
21
|
+
}
|
|
14
22
|
/**
|
|
15
23
|
* Advance one page when the drag passed this fraction of a page's width,
|
|
16
24
|
* matching the iOS SDK carousel. A shorter drag snaps back to the page the
|
|
@@ -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,
|