@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.
Files changed (42) hide show
  1. package/README.md +6 -0
  2. package/dist/components/carousel/Carousel.svelte +3 -6
  3. package/dist/components/carousel/carousel-utils.d.ts +6 -0
  4. package/dist/components/carousel/carousel-utils.js +8 -0
  5. package/dist/components/input-text/InputText.stories.svelte +92 -0
  6. package/dist/components/input-text/InputText.stories.svelte.d.ts +19 -0
  7. package/dist/components/input-text/InputText.svelte +281 -32
  8. package/dist/components/input-text/InputTextTestWrapper.svelte +14 -4
  9. package/dist/components/input-text/InputTextTestWrapper.svelte.d.ts +6 -0
  10. package/dist/components/input-text/pattern.d.ts +16 -0
  11. package/dist/components/input-text/pattern.js +24 -0
  12. package/dist/components/input-text/validity-rule.d.ts +15 -0
  13. package/dist/components/input-text/validity-rule.js +40 -0
  14. package/dist/components/options/InputMultipleChoice.svelte +5 -2
  15. package/dist/components/options/InputSingleChoice.svelte +5 -2
  16. package/dist/components/paywall/Paywall.stories.svelte +40 -0
  17. package/dist/components/paywall/Paywall.svelte +14 -7
  18. package/dist/components/paywall/Sheet.svelte +19 -6
  19. package/dist/components/paywall/ViewportBackdrop.svelte +13 -1
  20. package/dist/components/paywall/ViewportBackdrop.svelte.d.ts +1 -0
  21. package/dist/components/paywall/fixtures/gradient-sheet-paywall.d.ts +2 -0
  22. package/dist/components/paywall/fixtures/gradient-sheet-paywall.js +68 -0
  23. package/dist/components/paywall/fixtures/hero-media-paywall.d.ts +3 -0
  24. package/dist/components/paywall/fixtures/hero-media-paywall.js +182 -0
  25. package/dist/components/paywall/fixtures/sheet-close-button-paywall.d.ts +2 -0
  26. package/dist/components/paywall/fixtures/sheet-close-button-paywall.js +62 -0
  27. package/dist/components/paywall/paywall-utils.d.ts +2 -0
  28. package/dist/components/paywall/paywall-utils.js +22 -0
  29. package/dist/components/text/TextNode.svelte +2 -22
  30. package/dist/components/text/text-utils.d.ts +8 -0
  31. package/dist/components/text/text-utils.js +26 -0
  32. package/dist/components/workflows/Screen.svelte +5 -1
  33. package/dist/components/workflows/Workflow.svelte +2 -1
  34. package/dist/stores/inputValidation.d.ts +6 -3
  35. package/dist/stores/inputValidation.js +27 -4
  36. package/dist/stores/state.js +2 -2
  37. package/dist/types/components/input-text.d.ts +19 -0
  38. package/dist/types/overrides.d.ts +1 -1
  39. package/dist/types.d.ts +1 -1
  40. package/dist/utils/safe-area-background.js +1 -1
  41. package/dist/utils/style-utils.js +2 -2
  42. 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 { mapTextColor } from "../text/text-utils";
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.validity.valid;
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
- valid = input.validity.valid;
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.validity.valid;
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
- const wrapperStyle = $derived(
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
- ...(verticalAlignItems ? { "align-items": verticalAlignItems } : {}),
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": isFontRCFMManaged(font_name ?? "")
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": isFontRCFMManaged(
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-gradient-border" style={wrapperStyle}>
235
- <input
236
- {type}
237
- {placeholder}
238
- {required}
239
- inputmode={inputMode}
240
- autocapitalize={capitalize}
241
- style={inputStyle}
242
- {oninput}
243
- onfocus={() => (focused = true)}
244
- {onblur}
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
- localizations: {
46
- en_US: {},
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,