@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.
Files changed (28) hide show
  1. package/README.md +6 -0
  2. package/dist/components/input-text/InputText.stories.svelte +92 -0
  3. package/dist/components/input-text/InputText.stories.svelte.d.ts +19 -0
  4. package/dist/components/input-text/InputText.svelte +281 -32
  5. package/dist/components/input-text/InputTextTestWrapper.svelte +14 -4
  6. package/dist/components/input-text/InputTextTestWrapper.svelte.d.ts +6 -0
  7. package/dist/components/input-text/pattern.d.ts +16 -0
  8. package/dist/components/input-text/pattern.js +24 -0
  9. package/dist/components/input-text/validity-rule.d.ts +15 -0
  10. package/dist/components/input-text/validity-rule.js +40 -0
  11. package/dist/components/options/InputMultipleChoice.svelte +5 -2
  12. package/dist/components/options/InputSingleChoice.svelte +5 -2
  13. package/dist/components/paywall/Paywall.stories.svelte +26 -0
  14. package/dist/components/paywall/Paywall.svelte +12 -6
  15. package/dist/components/paywall/fixtures/hero-media-paywall.d.ts +3 -0
  16. package/dist/components/paywall/fixtures/hero-media-paywall.js +182 -0
  17. package/dist/components/paywall/paywall-utils.d.ts +2 -0
  18. package/dist/components/paywall/paywall-utils.js +22 -0
  19. package/dist/components/text/TextNode.svelte +2 -22
  20. package/dist/components/text/text-utils.d.ts +8 -0
  21. package/dist/components/text/text-utils.js +26 -0
  22. package/dist/components/workflows/Screen.svelte +5 -1
  23. package/dist/stores/inputValidation.d.ts +6 -3
  24. package/dist/stores/inputValidation.js +27 -4
  25. package/dist/types/components/input-text.d.ts +19 -0
  26. package/dist/types/overrides.d.ts +1 -1
  27. package/dist/types.d.ts +1 -1
  28. 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 { 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,
@@ -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(unsubscribe);
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(unsubscribe);
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 { mapPaywallContentStackStyle } from "./paywall-utils";
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 firstComponent = stack.components[0];
429
- const firstComponentIsFullWidthImage =
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,3 @@
1
+ import type { PaywallData } from "../../../types/paywall";
2
+ export type HeroMediaType = "image" | "video" | "web_view";
3
+ export declare const createHeroMediaPaywall: (type: HeroMediaType) => PaywallData;
@@ -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
- if (
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 id={containerId} class="paywall-container">
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
- updateSatisfaction: (satisfied: boolean) => void;
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 isSatisfied = writable(initiallySatisfied);
7
- const updateSatisfaction = (satisfied) => {
8
- isSatisfied.set(satisfied);
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";
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@revenuecat/purchases-ui-js",
3
3
  "description": "Web components for Paywalls. Powered by RevenueCat",
4
4
  "private": false,
5
- "version": "4.8.22",
5
+ "version": "4.8.23",
6
6
  "author": {
7
7
  "name": "RevenueCat, Inc."
8
8
  },