@authhero/widget 0.40.2 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +7,11 @@ import type { WidgetBranding, WidgetTheme } from "../../utils/branding";
7
7
  export interface SubmitEventDetail {
8
8
  /** The current screen configuration */
9
9
  screen: UiScreen;
10
- /** Form field values keyed by component ID */
11
- data: Record<string, string>;
10
+ /**
11
+ * Form field values keyed by component ID. Prompt-partial `ulp-*` fields
12
+ * that submit several values (checkbox groups, multi-selects) are arrays.
13
+ */
14
+ data: Record<string, string | string[]>;
12
15
  }
13
16
  /**
14
17
  * Button click event detail - emitted when a non-submit button is clicked
@@ -272,6 +275,13 @@ export declare class AuthheroWidget {
272
275
  * actually in the DOM before measuring its height.
273
276
  */
274
277
  private pendingRenderResolvers;
278
+ /**
279
+ * Number of `swapScreen` calls still running (rendering or animating the
280
+ * card height). A counter rather than a flag because a newer swap can start
281
+ * before a superseded one has settled.
282
+ */
283
+ private activeSwaps;
284
+ private updateReadyAttribute;
275
285
  componentDidRender(): void;
276
286
  /**
277
287
  * Resolves after Stencil flushes the next render, so `swapScreen` waits for
@@ -292,6 +302,7 @@ export declare class AuthheroWidget {
292
302
  * or the user prefers reduced motion.
293
303
  */
294
304
  private swapScreen;
305
+ private animateSwap;
295
306
  /**
296
307
  * Get the effective autoNavigate value (defaults to autoSubmit if not set)
297
308
  */
@@ -320,6 +331,15 @@ export declare class AuthheroWidget {
320
331
  */
321
332
  private readJsonScript;
322
333
  componentWillLoad(): Promise<void>;
334
+ /**
335
+ * Carry over text the user (or a password manager, or a test driver) put
336
+ * into the server-rendered inputs before the client bundle loaded. Those
337
+ * inputs had no `onInput` handler yet, so `formData` never saw the value:
338
+ * the first client render would reset them to "" and keep the submit
339
+ * button disabled. The declarative shadow roots are still the SSR ones
340
+ * here — `componentWillLoad` runs before the first client render.
341
+ */
342
+ private adoptPrehydrationValues;
323
343
  /**
324
344
  * Fetch screen configuration from the API
325
345
  * @param screenIdOverride Optional screen ID to fetch (overrides this.screenId)
@@ -411,5 +431,7 @@ export declare class AuthheroWidget {
411
431
  * Visible label of a choice button, used to filter searchable choice lists.
412
432
  */
413
433
  private getChoiceButtonText;
434
+ private renderPromptPartial;
435
+ private renderScreenLinks;
414
436
  render(): any;
415
437
  }
package/hydrate/index.js CHANGED
@@ -7236,7 +7236,7 @@ function escapeAttr(value) {
7236
7236
  .replace(/>/g, "&gt;");
7237
7237
  }
7238
7238
 
7239
- const authheroWidgetCss = () => `:host{display:block;font-family:var( --ah-font-family, "ulp-font", -apple-system, BlinkMacSystemFont, Roboto, Helvetica, sans-serif );font-size:var(--ah-font-size-base, 16px);line-height:var(--ah-line-height-base, 1.5);color:var(--ah-color-text, #1e212a);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.widget-container{width:var(--ah-widget-max-width, 400px);margin:0 auto;background-color:var(--ah-color-bg, #ffffff);border-radius:var(--ah-widget-radius, 5px);border:var(--ah-widget-border-width, 0) solid var(--ah-widget-border-color, transparent);box-shadow:var(--ah-widget-shadow, 0 4px 22px 0 rgba(0, 0, 0, 0.11));box-sizing:border-box}.widget-header{padding:var(--ah-header-padding, 40px 48px 24px)}.widget-body{padding:var(--ah-body-padding, 0 48px 40px)}.logo-wrapper{display:var(--ah-logo-display, flex);justify-content:var(--ah-logo-align, center);margin-bottom:8px}.logo{display:block;height:var(--ah-logo-height, 52px);max-width:100%;width:auto;object-fit:contain}.title{font-size:var(--ah-font-size-title, 24px);font-weight:var(--ah-font-weight-title, 700);text-align:var(--ah-title-align, center);margin:var(--ah-title-margin, 24px 0 24px);color:var(--ah-color-text-header, var(--ah-color-header, #1e212a));line-height:1.2}.description{font-size:var(--ah-font-size-subtitle, 16px);font-weight:var(--ah-font-weight-subtitle, 400);text-align:var(--ah-title-align, center);margin:var(--ah-description-margin, 0 0 8px);color:var(--ah-color-text, #1e212a);line-height:1.5}.message{padding:12px 16px;border-radius:4px;margin-bottom:16px;font-size:14px;line-height:1.5}.message-error{background-color:var(--ah-color-error-bg, #ffeaea);color:var(--ah-color-error, #d03c38);border-left:3px solid var(--ah-color-error, #d03c38)}.message-success{background-color:var(--ah-color-success-bg, #e6f9f1);color:var(--ah-color-success, #13a769);border-left:3px solid var(--ah-color-success, #13a769)}form{display:flex;flex-direction:column}.form-content{display:flex;flex-direction:column}.social-section{display:flex;flex-direction:column;gap:8px;order:var(--ah-social-order, 2)}.fields-section{display:flex;flex-direction:column;order:var(--ah-fields-order, 0)}.fields-list{display:flex;flex-direction:column}.fields-list-scroll{max-height:var(--ah-choice-list-max-height, 320px);overflow-y:auto;gap:var(--ah-choice-list-gap, 8px);padding-right:4px}.choice-list-search{width:100%;padding:16px;margin-bottom:12px;font-size:16px;font-family:inherit;color:var(--ah-color-input-text, var(--ah-color-text, #1e212a));background-color:var(--ah-color-input-bg, #ffffff);border:var(--ah-input-border-width, 1px) solid var(--ah-color-border, #c9cace);border-radius:var(--ah-input-radius, 3px);outline:none;box-sizing:border-box;transition:border-color 0.15s ease-out, box-shadow 0.15s ease-out}.choice-list-search:hover{border-color:var(--ah-color-border-hover, #65676e)}.choice-list-search:focus{border-color:var(--ah-color-primary, var(--ah-color-link, #635dff))}.choice-list-empty{padding:16px;text-align:center;font-size:14px;color:var(--ah-color-text-muted, #65676e)}.divider{display:flex;align-items:center;text-align:center;margin:16px 0;order:var(--ah-divider-order, 1)}.divider::before,.divider::after{content:"";flex:1;border-bottom:1px solid var(--ah-color-border-muted, #c9cace)}.divider-text{padding:0 10px;font-size:12px;font-weight:400;color:var(--ah-color-text-muted, #65676e);text-transform:uppercase;letter-spacing:0}.links{display:flex;flex-direction:column;align-items:center;gap:8px;margin-top:16px}.link-wrapper{font-size:var(--ah-font-size-body, 14px);font-weight:var(--ah-font-weight-body, 400);color:var(--ah-color-text, #1e212a)}.link{color:var(--ah-color-link, #635dff);text-decoration:var(--ah-link-decoration, none);font-size:var(--ah-font-size-link, 14px);font-weight:var(--ah-font-weight-link, 400);transition:color 150ms ease}.link:hover{text-decoration:underline}.link:focus-visible{outline:2px solid var(--ah-color-focus-ring, var(--ah-color-link, #635dff));outline-offset:2px;border-radius:2px}.widget-footer{margin-top:16px;text-align:center;font-size:12px;color:var(--ah-color-text-muted, #65676e)}.widget-footer a{color:var(--ah-color-link, #635dff);text-decoration:var(--ah-link-decoration, none);font-size:12px;transition:color 150ms ease}.widget-footer a:hover{text-decoration:underline}.widget-footer a:focus-visible{outline:2px solid var(--ah-color-focus-ring, var(--ah-color-link, #635dff));outline-offset:2px;border-radius:2px}.loading-spinner{width:32px;height:32px;margin:24px auto;border:3px solid var(--ah-color-border-muted, #e0e1e3);border-top-color:var(--ah-color-primary, #635dff);border-radius:50%;animation:spin 0.8s linear infinite}@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}.error-message{text-align:center;color:var(--ah-color-error, #d03c38);padding:16px;font-size:14px}@media (max-width: 480px){:host(:not([floating])){display:block;width:100%;min-height:100vh;background-color:var(--ah-color-bg, #ffffff)}:host(:not([floating])) .widget-container{box-shadow:none;border-radius:0;border:0;width:100%;margin:0}:host([floating]) .widget-container{width:100%;margin:0}.widget-header{padding:24px 16px 16px}.widget-body{padding:0 16px 24px}}`;
7239
+ const authheroWidgetCss = () => `:host{display:block;font-family:var( --ah-font-family, "ulp-font", -apple-system, BlinkMacSystemFont, Roboto, Helvetica, sans-serif );font-size:var(--ah-font-size-base, 16px);line-height:var(--ah-line-height-base, 1.5);color:var(--ah-color-text, #1e212a);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.widget-container{width:var(--ah-widget-max-width, 400px);margin:0 auto;background-color:var(--ah-color-bg, #ffffff);border-radius:var(--ah-widget-radius, 5px);border:var(--ah-widget-border-width, 0) solid var(--ah-widget-border-color, transparent);box-shadow:var(--ah-widget-shadow, 0 4px 22px 0 rgba(0, 0, 0, 0.11));box-sizing:border-box}.widget-header{padding:var(--ah-header-padding, 40px 48px 24px)}.widget-body{padding:var(--ah-body-padding, 0 48px 40px)}.logo-wrapper{display:var(--ah-logo-display, flex);justify-content:var(--ah-logo-align, center);margin-bottom:8px}.logo{display:block;height:var(--ah-logo-height, 52px);max-width:100%;width:auto;object-fit:contain}.title{font-size:var(--ah-font-size-title, 24px);font-weight:var(--ah-font-weight-title, 700);text-align:var(--ah-title-align, center);margin:var(--ah-title-margin, 24px 0 24px);color:var(--ah-color-text-header, var(--ah-color-header, #1e212a));line-height:1.2}.description{font-size:var(--ah-font-size-subtitle, 16px);font-weight:var(--ah-font-weight-subtitle, 400);text-align:var(--ah-title-align, center);margin:var(--ah-description-margin, 0 0 8px);color:var(--ah-color-text, #1e212a);line-height:1.5}.message{padding:12px 16px;border-radius:4px;margin-bottom:16px;font-size:14px;line-height:1.5}.message-error{background-color:var(--ah-color-error-bg, #ffeaea);color:var(--ah-color-error, #d03c38);border-left:3px solid var(--ah-color-error, #d03c38)}.message-success{background-color:var(--ah-color-success-bg, #e6f9f1);color:var(--ah-color-success, #13a769);border-left:3px solid var(--ah-color-success, #13a769)}form{display:flex;flex-direction:column}.form-footer,.secondary-actions,.prompt-partial{display:flex;flex-direction:column}.prompt-partial .ulp-field{display:flex;flex-direction:column;gap:6px;position:relative;margin-top:8px;margin-bottom:16px}.prompt-partial label{font-size:var(--ah-font-size-label, 14px);font-weight:var(--ah-font-weight-label, 400);color:var(--ah-color-text, #1e212a)}.prompt-partial .ulp-error input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),.prompt-partial .ulp-error select,.prompt-partial .ulp-error textarea{border-color:var(--ah-color-error, #d03c38)}.prompt-partial .ulp-error input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]):focus,.prompt-partial .ulp-error select:focus,.prompt-partial .ulp-error textarea:focus{box-shadow:inset 0 0 0 1px var(--ah-color-error, #d03c38)}.prompt-partial .ulp-error-info{font-size:12px;color:var(--ah-color-error, #d03c38);margin-top:-2px;line-height:1.4}.prompt-partial .required{color:var(--ah-color-error, #d03c38);margin-left:2px}.prompt-partial .ulp-field>input+label,.prompt-partial .ulp-field>select+label,.prompt-partial .ulp-field>textarea+label{position:absolute;left:16px;top:50%;z-index:1;padding:0;background-color:transparent;color:var(--ah-color-text-muted, #65676e);pointer-events:none;transform:translateY(-50%);transition:all 0.15s ease-out}.prompt-partial .ulp-field>input:focus+label,.prompt-partial .ulp-field>input:not(:placeholder-shown)+label,.prompt-partial .ulp-field>select+label,.prompt-partial .ulp-field>textarea:focus+label,.prompt-partial .ulp-field>textarea:not(:placeholder-shown)+label{top:-8px;left:12px;padding:0 4px;background-color:var(--ah-color-bg, #ffffff);color:var(--ah-color-text-muted, #65676e);font-size:12px;transform:translateY(0)}.prompt-partial .ulp-field>input:focus+label,.prompt-partial .ulp-field>select:focus+label,.prompt-partial .ulp-field>textarea:focus+label{color:var(--ah-color-primary, #635dff)}.prompt-partial input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),.prompt-partial select,.prompt-partial textarea{width:100%;padding:16px;font-size:16px;font-family:inherit;color:var(--ah-color-input-text, var(--ah-color-text, #1e212a));background-color:var(--ah-color-input-bg, #ffffff);border:var(--ah-input-border-width, 1px) solid var(--ah-color-border, #c9cace);border-radius:var(--ah-input-radius, 3px);outline:none;box-sizing:border-box;transition:border-color 0.15s ease-out, box-shadow 0.15s ease-out}.prompt-partial input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]):hover,.prompt-partial select:hover,.prompt-partial textarea:hover{border-color:var(--ah-color-border-hover, #65676e)}.prompt-partial input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]):focus,.prompt-partial select:focus,.prompt-partial textarea:focus{border-color:var(--ah-color-primary, #635dff);box-shadow:inset 0 0 0 1px var(--ah-color-primary, #635dff)}.form-content{display:flex;flex-direction:column}.social-section{display:flex;flex-direction:column;gap:8px;order:var(--ah-social-order, 2)}.fields-section{display:flex;flex-direction:column;order:var(--ah-fields-order, 0)}.fields-list{display:flex;flex-direction:column}.fields-list-scroll{max-height:var(--ah-choice-list-max-height, 320px);overflow-y:auto;gap:var(--ah-choice-list-gap, 8px);padding-right:4px}.choice-list-search{width:100%;padding:16px;margin-bottom:12px;font-size:16px;font-family:inherit;color:var(--ah-color-input-text, var(--ah-color-text, #1e212a));background-color:var(--ah-color-input-bg, #ffffff);border:var(--ah-input-border-width, 1px) solid var(--ah-color-border, #c9cace);border-radius:var(--ah-input-radius, 3px);outline:none;box-sizing:border-box;transition:border-color 0.15s ease-out, box-shadow 0.15s ease-out}.choice-list-search:hover{border-color:var(--ah-color-border-hover, #65676e)}.choice-list-search:focus{border-color:var(--ah-color-primary, var(--ah-color-link, #635dff))}.choice-list-empty{padding:16px;text-align:center;font-size:14px;color:var(--ah-color-text-muted, #65676e)}.divider{display:flex;align-items:center;text-align:center;margin:16px 0;order:var(--ah-divider-order, 1)}.divider::before,.divider::after{content:"";flex:1;border-bottom:1px solid var(--ah-color-border-muted, #c9cace)}.divider-text{padding:0 10px;font-size:12px;font-weight:400;color:var(--ah-color-text-muted, #65676e);text-transform:uppercase;letter-spacing:0}.links{display:flex;flex-direction:column;align-items:center;gap:8px;margin-top:16px}.link-wrapper{font-size:var(--ah-font-size-body, 14px);font-weight:var(--ah-font-weight-body, 400);color:var(--ah-color-text, #1e212a)}.link{color:var(--ah-color-link, #635dff);text-decoration:var(--ah-link-decoration, none);font-size:var(--ah-font-size-link, 14px);font-weight:var(--ah-font-weight-link, 400);transition:color 150ms ease}.link:hover{text-decoration:underline}.link:focus-visible{outline:2px solid var(--ah-color-focus-ring, var(--ah-color-link, #635dff));outline-offset:2px;border-radius:2px}.widget-footer{margin-top:16px;text-align:center;font-size:12px;color:var(--ah-color-text-muted, #65676e)}.widget-footer a{color:var(--ah-color-link, #635dff);text-decoration:var(--ah-link-decoration, none);font-size:12px;transition:color 150ms ease}.widget-footer a:hover{text-decoration:underline}.widget-footer a:focus-visible{outline:2px solid var(--ah-color-focus-ring, var(--ah-color-link, #635dff));outline-offset:2px;border-radius:2px}.loading-spinner{width:32px;height:32px;margin:24px auto;border:3px solid var(--ah-color-border-muted, #e0e1e3);border-top-color:var(--ah-color-primary, #635dff);border-radius:50%;animation:spin 0.8s linear infinite}@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}.error-message{text-align:center;color:var(--ah-color-error, #d03c38);padding:16px;font-size:14px}@media (max-width: 480px){:host(:not([floating])){display:block;width:100%;min-height:100vh;background-color:var(--ah-color-bg, #ffffff)}:host(:not([floating])) .widget-container{box-shadow:none;border-radius:0;border:0;width:100%;margin:0}:host([floating]) .widget-container{width:100%;margin:0}.widget-header{padding:24px 16px 16px}.widget-body{padding:0 16px 24px}}`;
7240
7240
 
7241
7241
  /**
7242
7242
  * When a screen's field section is made up entirely of choice buttons
@@ -7251,6 +7251,19 @@ const CHOICE_LIST_SEARCH_THRESHOLD = 5;
7251
7251
  * (CARDS, FILE, RECAPTCHA, …) can carry `required` in the schema too, and
7252
7252
  * gating on them would leave Continue disabled with no way to satisfy it.
7253
7253
  */
7254
+ /**
7255
+ * Field types rendered as a single plain text input whose value maps 1:1 to
7256
+ * formData. Composite fields (TEL, DATE) and choice/checkbox fields keep
7257
+ * internal state, so they're not adopted from pre-hydration markup.
7258
+ */
7259
+ const PREHYDRATION_TEXT_TYPES = new Set([
7260
+ "TEXT",
7261
+ "EMAIL",
7262
+ "CODE",
7263
+ "PASSWORD",
7264
+ "NUMBER",
7265
+ "URL",
7266
+ ]);
7254
7267
  const FILLABLE_FIELD_TYPES = new Set([
7255
7268
  "TEXT",
7256
7269
  "EMAIL",
@@ -7631,7 +7644,21 @@ class AuthheroWidget {
7631
7644
  * actually in the DOM before measuring its height.
7632
7645
  */
7633
7646
  pendingRenderResolvers = [];
7647
+ /**
7648
+ * Number of `swapScreen` calls still running (rendering or animating the
7649
+ * card height). A counter rather than a flag because a newer swap can start
7650
+ * before a superseded one has settled.
7651
+ */
7652
+ activeSwaps = 0;
7653
+ updateReadyAttribute() {
7654
+ }
7634
7655
  componentDidRender() {
7656
+ // `data-ready` marks the widget as interactive: hydrated in the browser
7657
+ // (the SSR markup already carries Stencil's `hydrated` class, so that
7658
+ // can't tell server and client apart) and not mid-submit or mid-swap.
7659
+ // Stencil defers this hook until child nodes have rendered, so the
7660
+ // fields are live too. E2E tests wait on `authhero-widget[data-ready]`.
7661
+ this.updateReadyAttribute();
7635
7662
  if (this.pendingRenderResolvers.length === 0)
7636
7663
  return;
7637
7664
  const resolvers = this.pendingRenderResolvers;
@@ -7679,6 +7706,18 @@ class AuthheroWidget {
7679
7706
  apply();
7680
7707
  return;
7681
7708
  }
7709
+ // Hold `data-ready` off until the new screen has rendered and the height
7710
+ // animation has finished; `apply()` clears `loading` before either.
7711
+ this.activeSwaps++;
7712
+ try {
7713
+ await this.animateSwap(card, apply);
7714
+ }
7715
+ finally {
7716
+ this.activeSwaps--;
7717
+ this.updateReadyAttribute();
7718
+ }
7719
+ }
7720
+ async animateSwap(card, apply) {
7682
7721
  // Lock the current height and clip, so swapping the content doesn't jump
7683
7722
  // before the animation runs.
7684
7723
  const startHeight = card.getBoundingClientRect().height;
@@ -7850,6 +7889,41 @@ class AuthheroWidget {
7850
7889
  await this.fetchScreen(this.screenId);
7851
7890
  }
7852
7891
  }
7892
+ /**
7893
+ * Carry over text the user (or a password manager, or a test driver) put
7894
+ * into the server-rendered inputs before the client bundle loaded. Those
7895
+ * inputs had no `onInput` handler yet, so `formData` never saw the value:
7896
+ * the first client render would reset them to "" and keep the submit
7897
+ * button disabled. The declarative shadow roots are still the SSR ones
7898
+ * here — `componentWillLoad` runs before the first client render.
7899
+ */
7900
+ adoptPrehydrationValues(screen) {
7901
+ const root = this.el.shadowRoot;
7902
+ if (!root)
7903
+ return;
7904
+ const roots = [root];
7905
+ root.querySelectorAll("authhero-node").forEach((node) => {
7906
+ if (node.shadowRoot)
7907
+ roots.push(node.shadowRoot);
7908
+ });
7909
+ const adopted = {};
7910
+ for (const component of screen.components ?? []) {
7911
+ if (!PREHYDRATION_TEXT_TYPES.has(component.type))
7912
+ continue;
7913
+ for (const r of roots) {
7914
+ const input = Array.from(r.querySelectorAll("input[name]")).find((el) => el.name === component.id && el.type !== "hidden");
7915
+ // Adopt "" too: a prefilled default the user cleared before
7916
+ // hydration must not be restored by the client render.
7917
+ if (input) {
7918
+ adopted[component.id] = input.value;
7919
+ break;
7920
+ }
7921
+ }
7922
+ }
7923
+ if (Object.keys(adopted).length > 0) {
7924
+ this.formData = { ...this.formData, ...adopted };
7925
+ }
7926
+ }
7853
7927
  /**
7854
7928
  * Fetch screen configuration from the API
7855
7929
  * @param screenIdOverride Optional screen ID to fetch (overrides this.screenId)
@@ -7951,14 +8025,44 @@ class AuthheroWidget {
7951
8025
  e.preventDefault();
7952
8026
  if (!this._screen || this.loading)
7953
8027
  return;
7954
- let submitData = { ...this.formData, ...(overrideData || {}) };
7955
- // Merge hidden input values from DOM (may have been set programmatically
7956
- // by inline scripts, e.g. passkey management buttons)
7957
8028
  const form = this.el.shadowRoot?.querySelector("form");
8029
+ if (this._screen.partials &&
8030
+ form?.reportValidity &&
8031
+ !form.reportValidity()) {
8032
+ return;
8033
+ }
8034
+ const submitData = {
8035
+ ...this.formData,
8036
+ ...(overrideData || {}),
8037
+ };
8038
+ // Native fields in prompt partials are direct descendants of this form,
8039
+ // unlike authhero-node inputs in nested shadow roots. Read them through
8040
+ // FormData so standard HTML controls and successful-control semantics are
8041
+ // preserved (disabled controls are skipped, repeated names are kept as
8042
+ // arrays). FormData also covers enabled hidden inputs for ulp-* names.
8043
+ const nativeNames = new Set();
8044
+ if (form && this._screen.partials) {
8045
+ const nativeValues = {};
8046
+ new FormData(form).forEach((value, key) => {
8047
+ if (typeof value === "string" && key.startsWith("ulp-")) {
8048
+ (nativeValues[key] ??= []).push(value);
8049
+ }
8050
+ });
8051
+ for (const [key, values] of Object.entries(nativeValues)) {
8052
+ nativeNames.add(key);
8053
+ submitData[key] = values.length > 1 ? values : values[0];
8054
+ }
8055
+ }
8056
+ // Legacy behaviour for inline scripts that fill hidden inputs: merge
8057
+ // enabled ones, but never overwrite what FormData already collected.
7958
8058
  if (form) {
7959
- const hiddenInputs = form.querySelectorAll('input[type="hidden"]');
7960
- hiddenInputs.forEach((input) => {
7961
- if (input.name && input.value) {
8059
+ form
8060
+ .querySelectorAll('input[type="hidden"]')
8061
+ .forEach((input) => {
8062
+ if (input.name &&
8063
+ input.value &&
8064
+ !input.disabled &&
8065
+ !nativeNames.has(input.name)) {
7962
8066
  submitData[input.name] = input.value;
7963
8067
  }
7964
8068
  });
@@ -8112,6 +8216,11 @@ class AuthheroWidget {
8112
8216
  if (!form)
8113
8217
  return;
8114
8218
  form.submit = () => {
8219
+ if (this._screen?.partials &&
8220
+ form.reportValidity &&
8221
+ !form.reportValidity()) {
8222
+ return;
8223
+ }
8115
8224
  const formData = new FormData(form);
8116
8225
  const data = {};
8117
8226
  formData.forEach((value, key) => {
@@ -8716,6 +8825,27 @@ class AuthheroWidget {
8716
8825
  const config = component.config;
8717
8826
  return config?.text ?? "";
8718
8827
  }
8828
+ renderPromptPartial(screen, insertionPoint) {
8829
+ const html = screen.partials?.[insertionPoint];
8830
+ if (!html)
8831
+ return null;
8832
+ // Prompt partials intentionally contain tenant-authored form controls, so
8833
+ // the widget must keep their input attributes intact.
8834
+ return (hAsync("div", { class: `prompt-partial prompt-partial-${insertionPoint}`, part: `prompt-partial prompt-partial-${insertionPoint}`, "data-insertion-point": insertionPoint, innerHTML: html }));
8835
+ }
8836
+ renderScreenLinks(screen) {
8837
+ if (!screen.links?.length)
8838
+ return null;
8839
+ return (hAsync("div", { class: "links", part: "links" }, screen.links.map((link) => (hAsync("span", { class: "link-wrapper", part: "link-wrapper", key: link.id ?? link.href }, link.linkText ? (hAsync("span", null, link.text, " ", hAsync("a", { href: link.href, class: "link", part: "link", onClick: (e) => this.handleLinkClick(e, {
8840
+ id: link.id,
8841
+ href: link.href,
8842
+ text: link.linkText || link.text,
8843
+ }) }, link.linkText))) : (hAsync("a", { href: link.href, class: "link", part: "link", onClick: (e) => this.handleLinkClick(e, {
8844
+ id: link.id,
8845
+ href: link.href,
8846
+ text: link.text,
8847
+ }) }, link.text)))))));
8848
+ }
8719
8849
  render() {
8720
8850
  const screen = this._screen;
8721
8851
  if (this.loading && !screen) {
@@ -8742,13 +8872,20 @@ class AuthheroWidget {
8742
8872
  ? components.find((c) => this.isDividerComponent(c))
8743
8873
  : undefined;
8744
8874
  const fieldComponents = components.filter((c) => !this.isSocialComponent(c) && c !== dividerComponent);
8875
+ const hasPromptPartials = Object.values(screen.partials ?? {}).some((html) => !!html);
8876
+ const footerComponents = hasPromptPartials
8877
+ ? fieldComponents.filter((component) => component.id === "submit" && component.type === "NEXT_BUTTON")
8878
+ : [];
8879
+ const contentFieldComponents = hasPromptPartials
8880
+ ? fieldComponents.filter((component) => !footerComponents.includes(component))
8881
+ : fieldComponents;
8745
8882
  const hasDivider = !!dividerComponent;
8746
8883
  const dividerText = dividerComponent?.config?.text || "Or";
8747
8884
  // A "choice list" screen is one whose entire field section is pick-one
8748
8885
  // buttons (e.g. tenant selection). When the list is long, render it as a
8749
8886
  // searchable, scrollable list instead of a tall column of buttons.
8750
- const choiceButtons = fieldComponents.filter((c) => c.type === "NEXT_BUTTON");
8751
- const isSearchableChoiceList = choiceButtons.length === fieldComponents.length &&
8887
+ const choiceButtons = contentFieldComponents.filter((c) => c.type === "NEXT_BUTTON");
8888
+ const isSearchableChoiceList = choiceButtons.length === contentFieldComponents.length &&
8752
8889
  choiceButtons.length > CHOICE_LIST_SEARCH_THRESHOLD;
8753
8890
  // Hold the primary action button disabled until every required field has
8754
8891
  // a value.
@@ -8789,7 +8926,7 @@ class AuthheroWidget {
8789
8926
  };
8790
8927
  // Get logo URL from theme.widget (takes precedence) or branding
8791
8928
  const logoUrl = this._theme?.widget?.logo_url || this._branding?.logo_url;
8792
- return (hAsync("div", { class: "widget-container", part: "container", "data-authstack-container": true }, hAsync("header", { class: "widget-header", part: "header" }, logoUrl && (hAsync("div", { class: "logo-wrapper", part: "logo-wrapper" }, hAsync("img", { class: "logo", part: "logo", src: logoUrl, alt: "Logo" }))), screen.title && (hAsync("h1", { class: "title", part: "title", innerHTML: sanitizeHtml(screen.title) })), screen.description && (hAsync("p", { class: "description", part: "description", innerHTML: sanitizeHtml(screen.description) }))), hAsync("div", { class: "widget-body", part: "body" }, screenErrors.map((err) => (hAsync("div", { class: "message message-error", part: "message message-error", key: err.id ?? err.text }, err.text))), screenSuccesses.map((msg) => (hAsync("div", { class: "message message-success", part: "message message-success", key: msg.id ?? msg.text }, msg.text))), hAsync("form", { onSubmit: this.handleSubmit, action: screen.action, method: screen.method || "POST", part: "form" }, hiddenComponents.map((c) => (hAsync("input", { type: "hidden", name: c.id, id: c.id, key: c.id, value: this.formData[c.id] || "" }))), hAsync("div", { class: "form-content" }, socialComponents.length > 0 && (hAsync("div", { class: "social-section", part: "social-section" }, socialComponents.map((component) => (hAsync("authhero-node", { key: component.id, component: component, locale: this.locale, value: this.formData[component.id], onFieldChange: (e) => this.handleInputChange(e.detail.id, e.detail.value), onButtonClick: (e) => this.handleButtonClick(e.detail), disabled: this.loading, exportparts: getExportParts(component) }))))), socialComponents.length > 0 &&
8929
+ return (hAsync("div", { class: "widget-container", part: "container", "data-authstack-container": true }, hAsync("header", { class: "widget-header", part: "header" }, logoUrl && (hAsync("div", { class: "logo-wrapper", part: "logo-wrapper" }, hAsync("img", { class: "logo", part: "logo", src: logoUrl, alt: "Logo" }))), screen.title && (hAsync("h1", { class: "title", part: "title", innerHTML: sanitizeHtml(screen.title) })), screen.description && (hAsync("p", { class: "description", part: "description", innerHTML: sanitizeHtml(screen.description) }))), hAsync("div", { class: "widget-body", part: "body" }, screenErrors.map((err) => (hAsync("div", { class: "message message-error", part: "message message-error", key: err.id ?? err.text }, err.text))), screenSuccesses.map((msg) => (hAsync("div", { class: "message message-success", part: "message message-success", key: msg.id ?? msg.text }, msg.text))), hAsync("form", { onSubmit: this.handleSubmit, action: screen.action, method: screen.method || "POST", part: "form" }, hiddenComponents.map((c) => (hAsync("input", { type: "hidden", name: c.id, id: c.id, key: c.id, value: this.formData[c.id] || "" }))), hAsync("div", { class: "form-content" }, this.renderPromptPartial(screen, "form-content-start"), socialComponents.length > 0 && (hAsync("div", { class: "social-section", part: "social-section" }, socialComponents.map((component) => (hAsync("authhero-node", { key: component.id, component: component, locale: this.locale, value: this.formData[component.id], onFieldChange: (e) => this.handleInputChange(e.detail.id, e.detail.value), onButtonClick: (e) => this.handleButtonClick(e.detail), disabled: this.loading, exportparts: getExportParts(component) }))))), socialComponents.length > 0 &&
8793
8930
  fieldComponents.length > 0 &&
8794
8931
  hasDivider && (hAsync("div", { class: "divider", part: "divider" }, hAsync("span", { class: "divider-text" }, dividerText))), hAsync("div", { class: "fields-section", part: "fields-section" }, isSearchableChoiceList && (hAsync("input", { type: "text", class: "choice-list-search", part: "choice-list-search", placeholder: "Search", "aria-label": "Search", autocomplete: "off", value: this.listFilter, onInput: (e) => (this.listFilter = e.target.value), onKeyDown: (e) => {
8795
8932
  // The search box lives inside the form; Enter would
@@ -8802,19 +8939,14 @@ class AuthheroWidget {
8802
8939
  ? "fields-list fields-list-scroll"
8803
8940
  : "fields-list" }, (isSearchableChoiceList
8804
8941
  ? visibleChoiceButtons
8805
- : fieldComponents).map((component) => (hAsync("authhero-node", { key: component.id, component: component, locale: this.locale, value: this.formData[component.id], onFieldChange: (e) => this.handleInputChange(e.detail.id, e.detail.value), onButtonClick: (e) => this.handleButtonClick(e.detail), disabled: this.loading ||
8942
+ : contentFieldComponents).map((component) => (hAsync("authhero-node", { key: component.id, component: component, locale: this.locale, value: this.formData[component.id], onFieldChange: (e) => this.handleInputChange(e.detail.id, e.detail.value), onButtonClick: (e) => this.handleButtonClick(e.detail), disabled: this.loading ||
8806
8943
  (component.type === "NEXT_BUTTON" &&
8807
8944
  component.config?.skip_validation !== true &&
8808
8945
  requiredFieldsMissing) }))), isSearchableChoiceList &&
8809
- visibleChoiceButtons.length === 0 && (hAsync("div", { class: "choice-list-empty", part: "choice-list-empty" }, "No matches")))))), screen.links && screen.links.length > 0 && (hAsync("div", { class: "links", part: "links" }, screen.links.map((link) => (hAsync("span", { class: "link-wrapper", part: "link-wrapper", key: link.id ?? link.href }, link.linkText ? (hAsync("span", null, link.text, " ", hAsync("a", { href: link.href, class: "link", part: "link", onClick: (e) => this.handleLinkClick(e, {
8810
- id: link.id,
8811
- href: link.href,
8812
- text: link.linkText || link.text,
8813
- }) }, link.linkText))) : (hAsync("a", { href: link.href, class: "link", part: "link", onClick: (e) => this.handleLinkClick(e, {
8814
- id: link.id,
8815
- href: link.href,
8816
- text: link.text,
8817
- }) }, link.text))))))), screen.footer && (hAsync("div", { class: "widget-footer", part: "footer", innerHTML: sanitizeHtml(screen.footer) })))));
8946
+ visibleChoiceButtons.length === 0 && (hAsync("div", { class: "choice-list-empty", part: "choice-list-empty" }, "No matches")))), this.renderPromptPartial(screen, "form-content-end"), hasPromptPartials && (hAsync("div", { class: "form-footer", part: "form-footer" }, this.renderPromptPartial(screen, "form-footer-start"), footerComponents.map((component) => (hAsync("authhero-node", { key: component.id, component: component, locale: this.locale, value: this.formData[component.id], onFieldChange: (e) => this.handleInputChange(e.detail.id, e.detail.value), onButtonClick: (e) => this.handleButtonClick(e.detail), disabled: this.loading ||
8947
+ (component.type === "NEXT_BUTTON" &&
8948
+ component.config?.skip_validation !== true &&
8949
+ requiredFieldsMissing) }))), this.renderPromptPartial(screen, "form-footer-end")))), hasPromptPartials && (hAsync("div", { class: "secondary-actions", part: "secondary-actions" }, this.renderPromptPartial(screen, "secondary-actions-start"), this.renderScreenLinks(screen), this.renderPromptPartial(screen, "secondary-actions-end")))), !hasPromptPartials && this.renderScreenLinks(screen), screen.footer && (hAsync("div", { class: "widget-footer", part: "footer", innerHTML: sanitizeHtml(screen.footer) })))));
8818
8950
  }
8819
8951
  static get watchers() { return {
8820
8952
  "screenId": [{