diff --git a/classes/controllers/FrmEntriesAJAXSubmitController.php b/classes/controllers/FrmEntriesAJAXSubmitController.php
index 00004b7c21..3ce89a3f18 100644
--- a/classes/controllers/FrmEntriesAJAXSubmitController.php
+++ b/classes/controllers/FrmEntriesAJAXSubmitController.php
@@ -73,13 +73,19 @@ public static function ajax_create() {
}
$response['errors'] = $obj;
- $invalid_msg = FrmFormsHelper::get_invalid_error_message( array( 'form' => $form ) );
+ $invalid_msg = FrmFormsHelper::get_invalid_error_message(
+ array(
+ 'form' => $form,
+ 'errors' => $errors,
+ )
+ );
$response['error_message'] = FrmFormsHelper::get_success_message(
array(
'message' => $invalid_msg,
'form' => $form,
'entry_id' => 0,
'class' => FrmFormsHelper::form_error_class(),
+ 'role' => 'alert',
)
);
} else {
diff --git a/classes/helpers/FrmFormsHelper.php b/classes/helpers/FrmFormsHelper.php
index 483050f386..fe4d78106b 100644
--- a/classes/helpers/FrmFormsHelper.php
+++ b/classes/helpers/FrmFormsHelper.php
@@ -288,11 +288,210 @@ public static function get_invalid_error_message( $args ) {
$settings_args['current_form'] = $args['form']->id;
}
- $frm_settings = FrmAppHelper::get_settings( $settings_args );
- $invalid_msg = do_shortcode( $frm_settings->invalid_msg );
+ $frm_settings = FrmAppHelper::get_settings( $settings_args );
+ $field_error_messages = self::get_clickable_field_error_messages( $args );
+ $invalid_msg = '' . do_shortcode( $frm_settings->invalid_msg ) . '';
+
+ if ( $field_error_messages ) {
+ $invalid_msg .= "
";
+ }
+
return apply_filters( 'frm_invalid_error_message', $invalid_msg, $args );
}
+ /**
+ * Get clickable field error messages.
+ *
+ * @since x.x
+ *
+ * @param array $args
+ *
+ * @return string
+ */
+ private static function get_clickable_field_error_messages( $args ) {
+ if ( empty( $args['errors'] ) ) {
+ return '';
+ }
+
+ /**
+ * Allows the list of clickable field errors to be turned off, leaving only the invalid
+ * message on its own. Return false to opt a site, or a single form, out of the summary.
+ *
+ * @since x.x
+ *
+ * @param bool $show_summary Whether to list each field that failed validation.
+ * @param array $args Includes 'form' and 'errors'.
+ */
+ if ( ! apply_filters( 'frm_show_clickable_field_errors', true, $args ) ) {
+ return '';
+ }
+
+ // Parse each error key once into its field ID and container ID, skipping
+ // non-field errors like 'form' or 'spam' that have no input to link to.
+ $parsed_errors = array();
+ $field_ids = array();
+
+ foreach ( $args['errors'] as $field_plus_id => $error ) {
+ if ( ! str_starts_with( $field_plus_id, 'field' ) ) {
+ continue;
+ }
+
+ if ( ! is_string( $error ) || '' === trim( $error ) ) {
+ // A combo field flags a sub field that failed with an empty error, which is a marker
+ // for the input rather than a message. Listing it would add an empty link.
+ continue;
+ }
+
+ // Everything after the 'field' prefix identifies the field in the DOM. It is the field
+ // ID on its own, plus a '-{sub_field}' suffix for a combo sub field such as a name or
+ // address line, a '-{section_id}-{row}' suffix for a field in a repeater row, or both.
+ $key_parts = explode( '-', substr( $field_plus_id, strlen( 'field' ) ) );
+ $field_id = $key_parts[0];
+
+ if ( ! is_numeric( $field_id ) ) {
+ continue;
+ }
+
+ if ( count( $key_parts ) > 3 ) {
+ // A combo sub field inside a repeater row has no container of its own, so link to
+ // the row's container for the whole field. The front end script picks the sub input
+ // that failed out of it.
+ $key_parts = array_slice( $key_parts, 0, 3 );
+ }
+
+ // Link to the field container rather than to an input. Every field type renders one,
+ // with the same suffixes the error key carries, while the ID of the input inside it
+ // differs per field type, and several types have no input matching the field key at
+ // all. The container is also what the front end script uses to find the input to
+ // focus, so this stays correct for field types this file knows nothing about.
+ $container_id = 'frm_field_' . implode( '-', $key_parts ) . '_container';
+
+ $field_ids[] = (int) $field_id;
+ $parsed_errors[] = compact( 'field_id', 'container_id', 'error' );
+ }//end foreach
+
+ if ( ! $field_ids ) {
+ return '';
+ }
+
+ $fields_by_id = self::get_error_fields_by_id( $args, $field_ids );
+
+ // Error messages are admin configured and may include shortcodes, so allow the same
+ // inline formatting Formidable permits elsewhere while stripping anything unsafe. Anchors
+ // are intentionally excluded so a message link cannot nest inside the summary link.
+ $allowed_tags = array( 'strong', 'b', 'em', 'i', 'u', 'span', 'code', 'br', 'sub', 'sup', 'mark', 'small' );
+ $field_error_messages = '';
+
+ foreach ( $parsed_errors as $parsed_error ) {
+ $field_id = (int) $parsed_error['field_id'];
+
+ if ( ! isset( $fields_by_id[ $field_id ] ) ) {
+ continue;
+ }
+
+ $field = $fields_by_id[ $field_id ];
+ $error = FrmAppHelper::kses( $parsed_error['error'], $allowed_tags );
+
+ if ( ! self::error_field_is_linkable( $field ) ) {
+ // The field has no focusable input on the page being shown (a hidden field, or a
+ // field on another page of a multi-page form), so list the error as plain text
+ // rather than a link that would go nowhere when clicked.
+ $field_error_messages .= '' . $error . '';
+ continue;
+ }
+
+ // The frm_error_link class is the hook the front end script uses to move focus into the
+ // field. Without JavaScript the browser still jumps to the container the link targets.
+ $field_error_messages .= '' . $error . '';
+ }//end foreach
+
+ return $field_error_messages;
+ }
+
+ /**
+ * Map the errored field IDs to field data.
+ *
+ * The fields for the form have already been loaded to render or validate the submission,
+ * so this reuses them (threaded down in $args, or the per-field cache warmed while
+ * validating the entry) and only queries for any field it still cannot resolve, keeping
+ * the error summary from adding a database round trip in the normal flow.
+ *
+ * @since x.x
+ *
+ * @param array $args Includes optional 'fields'.
+ * @param int[] $field_ids Field IDs referenced by the current errors.
+ *
+ * @return array Field objects keyed by field ID.
+ */
+ private static function get_error_fields_by_id( $args, $field_ids ) {
+ $fields_by_id = array();
+
+ // Prefer the fields already prepared for the form being shown, threaded down in $args.
+ if ( ! empty( $args['fields'] ) && is_array( $args['fields'] ) ) {
+ foreach ( $args['fields'] as $field ) {
+ // Display fields arrive as arrays; normalize to the object shape used below.
+ $field = (object) $field;
+
+ if ( isset( $field->id ) ) {
+ $fields_by_id[ (int) $field->id ] = $field;
+ }
+ }
+ }
+
+ // Next, the per-field cache warmed while validating the entry (getAll caches every
+ // field by id), so an AJAX submit resolves its errored fields without a query.
+ foreach ( array_diff( $field_ids, array_keys( $fields_by_id ) ) as $field_id ) {
+ $cached = FrmDb::check_cache( $field_id, 'frm_field' );
+
+ if ( is_object( $cached ) ) {
+ $fields_by_id[ $field_id ] = $cached;
+ }
+ }
+
+ // Only touch the database for fields that are still unresolved.
+ $missing = array_diff( $field_ids, array_keys( $fields_by_id ) );
+
+ if ( ! $missing ) {
+ return $fields_by_id;
+ }
+
+ $fields = FrmDb::get_results( 'frm_fields', array( 'id' => array_values( $missing ) ), 'id,field_key,type,field_order,form_id' );
+
+ foreach ( $fields as $field ) {
+ $fields_by_id[ (int) $field->id ] = $field;
+ }
+
+ return $fields_by_id;
+ }
+
+ /**
+ * Whether an error summary should link to the field, or just list its message as plain text.
+ *
+ * A link is only useful when the field renders a focusable input that is actually on the page
+ * being shown. Hidden and user ID fields render as hidden inputs that cannot receive focus, and
+ * a field on another page of a multi-page form is not visible, so neither should be linked.
+ *
+ * @since x.x
+ *
+ * @param stdClass $field Field row with at least type, field_order and form_id.
+ *
+ * @return bool
+ */
+ private static function error_field_is_linkable( $field ) {
+ if ( in_array( $field->type, array( 'hidden', 'user_id' ), true ) ) {
+ // Hidden inputs cannot receive focus, so there is nothing to link to.
+ return false;
+ }
+
+ if ( ! is_callable( 'FrmProFieldsHelper::field_on_current_page' ) ) {
+ // Multi-page forms are a Pro feature; in Lite every field is on the only page.
+ return true;
+ }
+
+ // On a multi-page form, do not link a field that is on a page other than the one being shown.
+ return FrmProFieldsHelper::field_on_current_page( $field );
+ }
+
/**
* @param array $atts {
* The success message details.
@@ -301,6 +500,9 @@ public static function get_invalid_error_message( $args ) {
* @type stdClass $form
* @type int $entry_id
* @type string $class
+ * @type string $role Optional. ARIA live region role for the wrapper. Defaults to 'status'.
+ * Pass 'alert' when the message reports a validation error so it is
+ * announced assertively, matching the non-ajax error wrapper.
* }
*
* @return string
@@ -328,7 +530,9 @@ public static function get_success_message( $atts ) {
}
$message = do_shortcode( $message );
- return '' . $message . '
';
+ $role = $atts['role'] ?? 'status';
+
+ return '' . $message . '
';
}
/**
diff --git a/classes/views/frm-entries/errors.php b/classes/views/frm-entries/errors.php
index c8fa444c92..26dfb2b70e 100644
--- a/classes/views/frm-entries/errors.php
+++ b/classes/views/frm-entries/errors.php
@@ -50,7 +50,14 @@
}
}
- FrmFormsHelper::show_errors( compact( 'img', 'errors', 'form' ) );
+ $error_args = compact( 'img', 'errors', 'form' );
+
+ if ( isset( $values['fields'] ) ) {
+ // Reuse the fields already prepared for this form so the summary needs no extra query.
+ $error_args['fields'] = $values['fields'];
+ }
+
+ FrmFormsHelper::show_errors( $error_args );
?>
diff --git a/css/_single_theme.css.php b/css/_single_theme.css.php
index 8d520cfb5c..e5ddc383c5 100644
--- a/css/_single_theme.css.php
+++ b/css/_single_theme.css.php
@@ -422,6 +422,26 @@
margin-bottom:var(--field-margin);
}
+. .frm_error_style span{
+ font-weight: bold;
+}
+
+. .frm_error_style ul{
+ list-style: inside;
+ color: var(--error-text);
+ margin-bottom: 0;
+ margin-left: 0;
+ list-style-position: outside;
+}
+
+. .frm_error_style ul li a{
+ color: var(--error-text);
+}
+
+. .frm_error_style ul li a:hover{
+ text-decoration: underline;
+}
+
. #frm_loading .progress-striped .progress-bar{
background-image:linear-gradient(45deg, 25%, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 0) 50%, 50%, 75%, rgba(0, 0, 0, 0) 75%, rgba(0, 0, 0, 0));
diff --git a/js/formidable.js b/js/formidable.js
index df4748d700..e3d7e8b757 100644
--- a/js/formidable.js
+++ b/js/formidable.js
@@ -5,6 +5,9 @@ function frmFrontFormJS() {
let jsErrors = [];
+ // Controls a field can hand focus to when an error summary link is clicked.
+ const FOCUSABLE_FIELD_SELECTOR = 'input:not([type="hidden"]), select, textarea, button, [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
+
/**
* Triggers custom JS event.
*
@@ -1497,6 +1500,94 @@ function frmFrontFormJS() {
}
}
+ /**
+ * Move focus into the field that an error summary link points at.
+ *
+ * The link targets the field container, not an input, because the ID of the input inside it
+ * varies by field type. Several types render no input matching the field key at all (name,
+ * address, time, star, scale, GDPR, ranking), and others render one that cannot take focus
+ * (the file field hides its input behind a dropzone, NPS and Likert use the field key on a
+ * wrapping div). Resolving the input here keeps every field type working, including types
+ * that come from add-ons.
+ *
+ * @since x.x
+ *
+ * @param {Event} event Click event on the summary link.
+ * @return {void}
+ */
+ function focusFieldFromErrorLink( event ) {
+ const href = this.getAttribute( 'href' );
+
+ if ( ! href || ! href.startsWith( '#' ) ) {
+ return;
+ }
+
+ const container = document.getElementById( href.substring( 1 ) );
+
+ if ( ! container ) {
+ return;
+ }
+
+ event.preventDefault();
+ container.scrollIntoView( { behavior: 'smooth', block: 'center' } );
+
+ const input = getFocusableInputInField( container );
+
+ if ( input ) {
+ focusInput( input );
+ return;
+ }
+
+ // Nothing inside can take focus, so focus the container instead. Its label is read out,
+ // which is still better than leaving focus on the summary link.
+ container.setAttribute( 'tabindex', '-1' );
+ focusInput( container );
+ }
+
+ /**
+ * Get the input an error summary link should move focus to.
+ *
+ * @since x.x
+ *
+ * @param {HTMLElement} container Field container.
+ * @return {HTMLElement|null} The input to focus, or null when the field has none.
+ */
+ function getFocusableInputInField( container ) {
+ const inputs = Array.from( container.querySelectorAll( FOCUSABLE_FIELD_SELECTOR ) ).filter( inputCanTakeFocus );
+
+ if ( ! inputs.length ) {
+ return null;
+ }
+
+ // A combo field such as name or address marks the sub field that failed validation, so
+ // prefer it over the first sub field.
+ return inputs.find( input => 'true' === input.getAttribute( 'aria-invalid' ) ) || inputs[ 0 ];
+ }
+
+ /**
+ * Check that an input is able to receive focus, so a summary link never focuses something
+ * the user cannot see. A hidden or zero sized input is skipped in favour of the visible
+ * control that stands in for it, for example the dropzone button of a file field.
+ *
+ * @since x.x
+ *
+ * @param {HTMLElement} input The input to test.
+ * @return {boolean} True when focusing the input would put the cursor somewhere visible.
+ */
+ function inputCanTakeFocus( input ) {
+ if ( input.disabled || 'hidden' === input.type ) {
+ return false;
+ }
+
+ const rect = input.getBoundingClientRect();
+
+ if ( ! rect.width && ! rect.height ) {
+ return false;
+ }
+
+ return 'hidden' !== getComputedStyle( input ).visibility;
+ }
+
/**
* Does the same as jQuery( document ).on( 'event', 'selector', handler ).
*
@@ -2206,6 +2297,9 @@ function frmFrontFormJS() {
// Focus on the first sub field when clicking to the primary label of combo field.
changeFocusWhenClickComboFieldLabel();
+ // Move focus into the field when an error summary link is clicked.
+ documentOn( 'click', '.frm_error_link', focusFieldFromErrorLink );
+
initFloatingLabels();
maybeShowNewTabFallbackMessage();
diff --git a/js/formidable.min.js b/js/formidable.min.js
index e524a764eb..5eb2609cac 100644
--- a/js/formidable.min.js
+++ b/js/formidable.min.js
@@ -1,7 +1,7 @@
-function frmFrontFormJS(){let jsErrors=[];function triggerCustomEvent(el,eventName,data){if(typeof window.CustomEvent!=="function")return;const event=new CustomEvent(eventName);event.frmData=data;el.dispatchEvent(event)}function getFieldId(field,fullID){let nameParts;let fieldId;let isRepeating=false;let fieldName="";if(field instanceof jQuery)field=field.get(0);fieldName=field.name;if(fieldName===undefined)fieldName="";if(fieldName===""){fieldName=field.getAttribute("data-name");if(fieldName===undefined)fieldName=
-"";if(fieldName!==""&&fieldName)return fieldName;return 0}nameParts=fieldName.replace("item_meta[","").replace("[]","").split("]");if(nameParts.length<1)return 0;nameParts=nameParts.filter(function(n){return n!==""});fieldId=nameParts[0];if(nameParts.length===1)return fieldId;if(nameParts[1]==="[form"||nameParts[1]==="[row_ids")return 0;if(document.querySelector(`input[name="item_meta[${fieldId}][form]"]`)){fieldId=nameParts[2].replace("[","");isRepeating=true}if("other"===fieldId)if(isRepeating)fieldId=
-nameParts[3].replace("[","");else fieldId=nameParts[1].replace("[","");if(fullID===true)if(fieldId===nameParts[0])fieldId=`${fieldId}-${nameParts[1].replace("[","")}`;else fieldId=`${fieldId}-${nameParts[0]}-${nameParts[1].replace("[","")}`;return fieldId}function disableSubmitButton($form){const form=$form instanceof jQuery?$form.get(0):$form;if(!form)return;form.querySelectorAll('input[type="submit"], input[type="button"], button[type="submit"], button.frm_save_draft').forEach(button=>button.disabled=
-true)}function enableSubmitButton(form){form.querySelectorAll('input[type="submit"], input[type="button"], button[type="submit"]').forEach(button=>button.disabled=false)}function disableSaveDraft($form){const form=$form instanceof jQuery?$form.get(0):$form;if(!form)return;form.querySelectorAll("a.frm_save_draft").forEach(link=>link.style.pointerEvents="none")}function enableSaveDraft($form){const form=$form instanceof jQuery?$form.get(0):$form;if(!form)return;form.querySelectorAll(".frm_save_draft").forEach(saveDraftButton=>
+function frmFrontFormJS(){let jsErrors=[];const FOCUSABLE_FIELD_SELECTOR='input:not([type="hidden"]), select, textarea, button, [contenteditable="true"], [tabindex]:not([tabindex="-1"])';function triggerCustomEvent(el,eventName,data){if(typeof window.CustomEvent!=="function")return;const event=new CustomEvent(eventName);event.frmData=data;el.dispatchEvent(event)}function getFieldId(field,fullID){let nameParts;let fieldId;let isRepeating=false;let fieldName="";if(field instanceof jQuery)field=field.get(0);
+fieldName=field.name;if(fieldName===undefined)fieldName="";if(fieldName===""){fieldName=field.getAttribute("data-name");if(fieldName===undefined)fieldName="";if(fieldName!==""&&fieldName)return fieldName;return 0}nameParts=fieldName.replace("item_meta[","").replace("[]","").split("]");if(nameParts.length<1)return 0;nameParts=nameParts.filter(function(n){return n!==""});fieldId=nameParts[0];if(nameParts.length===1)return fieldId;if(nameParts[1]==="[form"||nameParts[1]==="[row_ids")return 0;if(document.querySelector(`input[name="item_meta[${fieldId}][form]"]`)){fieldId=
+nameParts[2].replace("[","");isRepeating=true}if("other"===fieldId)if(isRepeating)fieldId=nameParts[3].replace("[","");else fieldId=nameParts[1].replace("[","");if(fullID===true)if(fieldId===nameParts[0])fieldId=`${fieldId}-${nameParts[1].replace("[","")}`;else fieldId=`${fieldId}-${nameParts[0]}-${nameParts[1].replace("[","")}`;return fieldId}function disableSubmitButton($form){const form=$form instanceof jQuery?$form.get(0):$form;if(!form)return;form.querySelectorAll('input[type="submit"], input[type="button"], button[type="submit"], button.frm_save_draft').forEach(button=>
+button.disabled=true)}function enableSubmitButton(form){form.querySelectorAll('input[type="submit"], input[type="button"], button[type="submit"]').forEach(button=>button.disabled=false)}function disableSaveDraft($form){const form=$form instanceof jQuery?$form.get(0):$form;if(!form)return;form.querySelectorAll("a.frm_save_draft").forEach(link=>link.style.pointerEvents="none")}function enableSaveDraft($form){const form=$form instanceof jQuery?$form.get(0):$form;if(!form)return;form.querySelectorAll(".frm_save_draft").forEach(saveDraftButton=>
{saveDraftButton.disabled=false;saveDraftButton.style.pointerEvents=""})}function validateForm(object){let errors=[];const vanillaJsObject="function"===typeof object.get?object.get(0):object;vanillaJsObject?.querySelectorAll(".frm_required_field").forEach(requiredField=>{const isVisible=requiredField.offsetParent!==null;if(!isVisible)return;requiredField.querySelectorAll("input, select, textarea").forEach(requiredInput=>{if(hasClass(requiredInput,"frm_optional")||hasClass(requiredInput,"ed_button"))return;
errors=checkRequiredField(requiredInput,errors)})});vanillaJsObject?.querySelectorAll("input,select,textarea").forEach(field=>{if(""===field.value){if("number"===field.type)checkValidity(field,errors);const isConfirmationField=field.name&&0===field.name.indexOf("item_meta[conf_");if(!isConfirmationField)return}validateFieldValue(field,errors,true);checkValidity(field,errors)});if(!hasInvisibleRecaptcha(object))errors=validateRecaptcha(object,errors);return errors}function checkValidity(field,errors){if("object"!==
typeof field.validity||false!==field.validity.valid)return;const fieldID=getFieldId(field,true);if(errors[fieldID]===undefined)errors[fieldID]=getFieldValidationMessage(field,"data-invmsg");if("function"===typeof field.reportValidity)field.reportValidity()}function hasClass(element,targetClass){return element.classList&&element.classList.contains(targetClass)}function maybeValidateChange(field){if(field.type==="url")maybeAddHttpsToUrl(field);const form=field.closest("form");if(!form)return;validateField(field,
@@ -47,13 +47,15 @@ if(enable==="enable"){enableSubmitButton(form);enableSaveDraft(form)}})}function
if(css?.match(/inset/))this.remove()}function changeFocusWhenClickComboFieldLabel(){let label;const comboInputsContainer=document.querySelectorAll(".frm_combo_inputs_container");comboInputsContainer.forEach(function(inputsContainer){if(!inputsContainer.closest(".frm_form_field"))return;label=inputsContainer.closest(".frm_form_field").querySelector(".frm_primary_label");if(!label)return;label.addEventListener("click",function(){inputsContainer.querySelector(".frm_form_field:first-child input, .frm_form_field:first-child select, .frm_form_field:first-child textarea").focus()})})}
function maybeFocusOnComboSubField(element){if("FIELDSET"!==element.nodeName)return false;if(!element.querySelector(".frm_combo_inputs_container"))return false;const comboSubfield=element.querySelector('[aria-invalid="true"]');if(comboSubfield){focusInput(comboSubfield);return true}return false}function checkForErrorsAndMaybeSetFocus(){if(!frm_js.focus_first_error)return;const errors=document.querySelectorAll(".frm_form_field .frm_error");if(!errors.length)return;let element=errors[0];let timeoutCallback;
do{element=element.previousSibling;if(["input","select","textarea"].includes(element.nodeName.toLowerCase())){focusInput(element);break}if(maybeFocusOnComboSubField(element))break;if(element.classList!==undefined){if(element.classList.contains("html-active"))timeoutCallback=function(){const textarea=element.querySelector("textarea");if(null!==textarea)textarea.focus()};else if(element.classList.contains("tmce-active"))timeoutCallback=function(){tinyMCE.activeEditor.focus()};else if(element.classList.contains("frm_opt_container")){const firstInput=
-element.querySelector("input");if(firstInput){focusInput(firstInput);break}}if("function"===typeof timeoutCallback){setTimeout(timeoutCallback,0);break}}}while(element.previousSibling)}function focusInput(input){if(input.offsetParent!==null)input.focus();else triggerCustomEvent(document,"frmMaybeDelayFocus",{input})}function documentOn(event,selector,handler,options){if(options===undefined)options=false;document.addEventListener(event,function(e){let target;for(target=e.target;target&&target!=this;target=
-target.parentNode)if(target.matches&&target.matches(selector)){handler.call(target,e);break}},options)}function initFloatingLabels(){const selector=".frm-show-form .frm_inside_container input, .frm-show-form .frm_inside_container select, .frm-show-form .frm_inside_container textarea";const floatClass="frm_label_float_top";const checkFloatLabel=function(input){const container=input.closest(".frm_inside_container");if(!container)return;const shouldFloatTop=input.value||document.activeElement===input;
-container.classList.toggle(floatClass,shouldFloatTop);if("SELECT"===input.tagName){const firstOpt=input.querySelector("option:first-child");if(shouldFloatTop){if(firstOpt.hasAttribute("data-label")){firstOpt.textContent=firstOpt.getAttribute("data-label");firstOpt.removeAttribute("data-label")}}else if(firstOpt.textContent){firstOpt.setAttribute("data-label",firstOpt.textContent);firstOpt.textContent=""}}};const checkDropdownLabel=function(){document.querySelectorAll(`.frm-show-form .frm_inside_container:not(.${floatClass}) select`).forEach(function(input){const firstOpt=
-input.querySelector("option:first-child");if(firstOpt.textContent){firstOpt.setAttribute("data-label",firstOpt.textContent);firstOpt.textContent=""}})};["focus","blur","change"].forEach(function(eventName){documentOn(eventName,selector,function(event){checkFloatLabel(event.target)},true)});const runOnLoad=function(firstLoad){if(firstLoad&&document.activeElement&&["INPUT","SELECT","TEXTAREA"].includes(document.activeElement.tagName))checkFloatLabel(document.activeElement);else if(firstLoad)document.querySelectorAll(".frm_inside_container").forEach(function(container){const input=
-container.querySelector("input, select, textarea");if(input&&""!==input.value)checkFloatLabel(input)});checkDropdownLabel();calcProductsTotal()};runOnLoad(true);jQuery(document).on("frmPageChanged",function(event){runOnLoad()});document.addEventListener("frm_after_start_over",function(event){runOnLoad()})}function shouldUpdateValidityMessage(target){if("INPUT"!==target.nodeName)return false;if(!target.dataset.invmsg)return false;if("text"!==target.getAttribute("type"))return false;if(target.classList.contains("frm_verify"))return false;
-return true}function maybeClearCustomValidityMessage(event,field){let key;let isInvalid=false;if(!shouldUpdateValidityMessage(field))return;for(key in field.validity){if("customError"===key)continue;if("valid"!==key&&field.validity[key]===true){isInvalid=true;break}}if(!isInvalid)field.setCustomValidity("")}function maybeShowNewTabFallbackMessage(){if(!window.frmShowNewTabFallback)return;const messageEl=document.querySelector(`#frm_form_${frmShowNewTabFallback.formId}_container .frm_message`);if(!messageEl)return;
-messageEl.insertAdjacentHTML("beforeend",` ${frmShowNewTabFallback.message}`)}function setCustomValidityMessage(){const forms=document.getElementsByClassName("frm-show-form");const {length}=forms;for(let index=0;index"true"===input.getAttribute("aria-invalid"))||inputs[0]}function inputCanTakeFocus(input){if(input.disabled||
+"hidden"===input.type)return false;const rect=input.getBoundingClientRect();if(!rect.width&&!rect.height)return false;return"hidden"!==getComputedStyle(input).visibility}function documentOn(event,selector,handler,options){if(options===undefined)options=false;document.addEventListener(event,function(e){let target;for(target=e.target;target&&target!=this;target=target.parentNode)if(target.matches&&target.matches(selector)){handler.call(target,e);break}},options)}function initFloatingLabels(){const selector=
+".frm-show-form .frm_inside_container input, .frm-show-form .frm_inside_container select, .frm-show-form .frm_inside_container textarea";const floatClass="frm_label_float_top";const checkFloatLabel=function(input){const container=input.closest(".frm_inside_container");if(!container)return;const shouldFloatTop=input.value||document.activeElement===input;container.classList.toggle(floatClass,shouldFloatTop);if("SELECT"===input.tagName){const firstOpt=input.querySelector("option:first-child");if(shouldFloatTop){if(firstOpt.hasAttribute("data-label")){firstOpt.textContent=
+firstOpt.getAttribute("data-label");firstOpt.removeAttribute("data-label")}}else if(firstOpt.textContent){firstOpt.setAttribute("data-label",firstOpt.textContent);firstOpt.textContent=""}}};const checkDropdownLabel=function(){document.querySelectorAll(`.frm-show-form .frm_inside_container:not(.${floatClass}) select`).forEach(function(input){const firstOpt=input.querySelector("option:first-child");if(firstOpt.textContent){firstOpt.setAttribute("data-label",firstOpt.textContent);firstOpt.textContent=
+""}})};["focus","blur","change"].forEach(function(eventName){documentOn(eventName,selector,function(event){checkFloatLabel(event.target)},true)});const runOnLoad=function(firstLoad){if(firstLoad&&document.activeElement&&["INPUT","SELECT","TEXTAREA"].includes(document.activeElement.tagName))checkFloatLabel(document.activeElement);else if(firstLoad)document.querySelectorAll(".frm_inside_container").forEach(function(container){const input=container.querySelector("input, select, textarea");if(input&&
+""!==input.value)checkFloatLabel(input)});checkDropdownLabel();calcProductsTotal()};runOnLoad(true);jQuery(document).on("frmPageChanged",function(event){runOnLoad()});document.addEventListener("frm_after_start_over",function(event){runOnLoad()})}function shouldUpdateValidityMessage(target){if("INPUT"!==target.nodeName)return false;if(!target.dataset.invmsg)return false;if("text"!==target.getAttribute("type"))return false;if(target.classList.contains("frm_verify"))return false;return true}function maybeClearCustomValidityMessage(event,
+field){let key;let isInvalid=false;if(!shouldUpdateValidityMessage(field))return;for(key in field.validity){if("customError"===key)continue;if("valid"!==key&&field.validity[key]===true){isInvalid=true;break}}if(!isInvalid)field.setCustomValidity("")}function maybeShowNewTabFallbackMessage(){if(!window.frmShowNewTabFallback)return;const messageEl=document.querySelector(`#frm_form_${frmShowNewTabFallback.formId}_container .frm_message`);if(!messageEl)return;messageEl.insertAdjacentHTML("beforeend",
+` ${frmShowNewTabFallback.message}`)}function setCustomValidityMessage(){const forms=document.getElementsByClassName("frm-show-form");const {length}=forms;for(let index=0;indexb.toString(16).padStart(2,"0")).join("");const timestamp=Date.now().toString(16);return`${uniqueKey}-${timestamp}`}function animateScroll(start,end,duration){if(!window.hasOwnProperty("performance")||!window.hasOwnProperty("requestAnimationFrame")){document.documentElement.scrollTop=
end;return}const startTime=performance.now();const step=currentTime=>{const progress=Math.min((currentTime-startTime)/duration,1);document.documentElement.scrollTop=start+(end-start)*progress;if(progress<1)requestAnimationFrame(step)};requestAnimationFrame(step)}function maybeFixCaptchaLabel(captcha){const form=captcha.closest("form");if(!form)return;const label=form.querySelector('label[for="g-recaptcha-response"], label[for="cf-turnstile-response"]');const captchaResponse=form.querySelector('[name="g-recaptcha-response"], [name="cf-turnstile-response"]');
if(label&&captchaResponse)label.htmlFor=captchaResponse.id}function checkQuantityFieldMinMax(input){if(""===input.value)return 0;const val=parseFloat(input.value?input.value.trim():0);if(isNaN(val))return 0;let max=input.hasAttribute("max")?parseFloat(input.getAttribute("max")):0;let min=input.hasAttribute("min")?parseFloat(input.getAttribute("min")):0;max=isNaN(max)?0:max;min=isNaN(min)?0:Math.max(0,min);if(valmax){input.value=max;return max}return val}
@@ -69,14 +71,14 @@ maybeUseDecimal(price,currency);price=price.split(currency.thousand_separator).j
currency){let usedForDecimal;let priceParts;if("."===currency.thousand_separator){priceParts=price.split(".");usedForDecimal=2===priceParts.length&&2===priceParts[1].length;if(usedForDecimal)price=price.replace(".",currency.decimal_separator)}return price}function maybeAddTrailingZeroToPrice(price,currency,force=false){if("number"!==typeof price&&!force)return price;price=String(price);const pos=price.indexOf(".");if(pos===-1){price=`${price}.`;for(let n=0;n!!input.value).length;if(hasFileFields<
-1){const actionInput=object.querySelector('input[name="frm_action"]');const action=actionInput?actionInput.value:"";frmFrontForm.checkFormErrors(object,action)}else object.submit()}else object.submit()},validateFormSubmit(object){const form=object instanceof jQuery?object.get(0):object;if(typeof tinyMCE!=="undefined"&&form?.querySelector(".wp-editor-wrap"))tinyMCE.triggerSave();jsErrors=[];if(shouldJSValidate(object)){frmFrontForm.getAjaxFormErrors(object);if(Object.keys(jsErrors).length)frmFrontForm.addAjaxFormErrors(object)}return jsErrors},
+frmFrontForm.fieldValueChanged);jQuery(document).on("change",".frm_verify[id^=field_]",onHoneypotFieldChange);jQuery(document).on("click","a[data-frmconfirm]",confirmClick);checkForErrorsAndMaybeSetFocus();changeFocusWhenClickComboFieldLabel();documentOn("click",".frm_error_link",focusFieldFromErrorLink);initFloatingLabels();maybeShowNewTabFallbackMessage();jQuery(document).on("frmAfterAddRow",setCustomValidityMessage);setCustomValidityMessage();jQuery(document).on("frmFieldChanged",maybeClearCustomValidityMessage);
+setSelectPlaceholderColor();jQuery(document).on("elementor/popup/show",frmRecaptcha);enableSubmitButtonOnBackButtonPress();jQuery(document).on("frmPageChanged",destroyhCaptcha);jQuery(document).on("frmAfterAddRow frmAfterRemoveRow",calcProductsTotal);jQuery(document).on("change",'[type="checkbox"][data-frmprice],[type="radio"][data-frmprice],[type="hidden"][data-frmprice],select:has([data-frmprice])',calcProductsTotal);jQuery(document).on("keyup change",'[data-frmproduct],[type="text"][data-frmprice]',
+calcProductsTotal);calcProductsTotal()},getFieldId,renderCaptcha(captcha,captchaSelector){const rendered=captcha.getAttribute("data-rid")!==null;if(rendered)return;const size=captcha.getAttribute("data-size");const params={sitekey:captcha.getAttribute("data-sitekey"),size,theme:captcha.getAttribute("data-theme")};if(size==="invisible"){const formID=captcha.closest("form")?.querySelector('input[name="form_id"]')?.value;const captchaLabel=captcha.closest(".frm_form_field")?.querySelector(".frm_primary_label");
+if(captchaLabel)captchaLabel.style.display="none";params.callback=function(token){frmFrontForm.afterRecaptcha(token,formID)}}const activeCaptcha=getSelectedCaptcha(captchaSelector);const captchaContainer=typeof turnstile!=="undefined"&&turnstile===activeCaptcha?`#${captcha.id}`:captcha.id;const captchaID=activeCaptcha.render(captchaContainer,params);captcha.setAttribute("data-rid",captchaID);maybeFixCaptchaLabel(captcha)},afterSingleRecaptcha(){const recaptcha=document.querySelector(".frm-show-form .g-recaptcha");
+const object=recaptcha?recaptcha.closest("form"):null;frmFrontForm.submitFormNow(object)},afterRecaptcha(_,formID){const object=document.querySelector(`#frm_form_${formID}_container form`);frmFrontForm.submitFormNow(object)},submitForm(e){frmFrontForm.submitFormManual(e,this)},submitFormManual(e,object){if(document.body.classList.contains("wp-admin")&&!object.closest(".frmapi-form"))return;e.preventDefault();if(typeof frmProForm!=="undefined"&&typeof frmProForm.submitAllowed==="function"&&!frmProForm.submitAllowed(object))return;
+const errors=frmFrontForm.validateFormSubmit(object);if(Object.keys(errors).length!==0)return;const invisibleRecaptcha=hasInvisibleRecaptcha(object);if(invisibleRecaptcha){showLoadingIndicator(jQuery(object));executeInvisibleRecaptcha(invisibleRecaptcha)}else{showSubmitLoading(jQuery(object));frmFrontForm.submitFormNow(object)}},submitFormNow(object){let hasFileFields;let antispamInput;const classList=object.className.trim().split(/\s+/gi);if(object.hasAttribute("data-token")&&null===object.querySelector('[name="antispam_token"]')){antispamInput=
+document.createElement("input");antispamInput.type="hidden";antispamInput.name="antispam_token";antispamInput.value=object.getAttribute("data-token");object.append(antispamInput)}const uniqueIDInput=document.createElement("input");uniqueIDInput.type="hidden";uniqueIDInput.name="unique_id";uniqueIDInput.value=getUniqueKey();object.append(uniqueIDInput);if(classList.includes("frm_ajax_submit")){const fileInputs=object.querySelectorAll('input[type="file"]');hasFileFields=Array.from(fileInputs).filter(input=>
+!!input.value).length;if(hasFileFields<1){const actionInput=object.querySelector('input[name="frm_action"]');const action=actionInput?actionInput.value:"";frmFrontForm.checkFormErrors(object,action)}else object.submit()}else object.submit()},validateFormSubmit(object){const form=object instanceof jQuery?object.get(0):object;if(typeof tinyMCE!=="undefined"&&form?.querySelector(".wp-editor-wrap"))tinyMCE.triggerSave();jsErrors=[];if(shouldJSValidate(object)){frmFrontForm.getAjaxFormErrors(object);if(Object.keys(jsErrors).length)frmFrontForm.addAjaxFormErrors(object)}return jsErrors},
getAjaxFormErrors(object){let customErrors;let key;const form=object instanceof jQuery?object.get(0):object;jsErrors=validateForm(object);if(typeof frmThemeOverride_jsErrors==="function"){const actionInput=form?form.querySelector('input[name="frm_action"]'):null;const action=actionInput?actionInput.value:"";customErrors=frmThemeOverride_jsErrors(action,object);if(Object.keys(customErrors).length)for(key in customErrors)jsErrors[key]=customErrors[key]}triggerCustomEvent(document,"frm_get_ajax_form_errors",
{formEl:object,errors:jsErrors});return jsErrors},addAjaxFormErrors(object){let key;const form=object instanceof jQuery?object.get(0):object;removeAllErrors();for(key in jsErrors){const fieldCont=form?form.querySelector(`#frm_field_${key}_container`):null;if(fieldCont)addFieldError(fieldCont,key,jsErrors);else delete jsErrors[key]}scrollToFirstField(object);checkForErrorsAndMaybeSetFocus()},checkFormErrors:getFormErrors,checkRequiredField,showSubmitLoading,removeSubmitLoading,scrollToID(id){const object=
jQuery(document.getElementById(id));frmFrontForm.scrollMsg(object,false)},scrollMsg(id,object,animate){let newPos;let screenTop;let screenBottom;let scrollObj="";if(object===undefined){scrollObj=jQuery(document.getElementById(`frm_form_${id}_container`));if(scrollObj.length<1)return}else if(typeof id==="string"){const formEl=object instanceof jQuery?object.get(0):object;const fieldEl=formEl?formEl.querySelector(`#frm_field_${id}_container`):null;scrollObj=fieldEl?jQuery(fieldEl):jQuery()}else scrollObj=
diff --git a/stubs.php b/stubs.php
index d2fbe140c7..7edadbe48d 100644
--- a/stubs.php
+++ b/stubs.php
@@ -316,6 +316,13 @@ public static function replace_non_standard_formidable_shortcodes( $args, &$valu
*/
public static function is_field_visible_to_user( $field ) {
}
+ /**
+ * @param array|int|object $field
+ *
+ * @return bool
+ */
+ public static function field_on_current_page( $field ) {
+ }
}
class FrmViewsAppHelper {
/**
diff --git a/tests/phpunit/forms/test_FrmFormsHelper.php b/tests/phpunit/forms/test_FrmFormsHelper.php
index 41a294d59d..0b66f8cd20 100644
--- a/tests/phpunit/forms/test_FrmFormsHelper.php
+++ b/tests/phpunit/forms/test_FrmFormsHelper.php
@@ -146,4 +146,380 @@ private function create_form_with_custom_style_value( $custom_style ) {
)
);
}
+
+ /**
+ * The invalid error message should include a list of links that jump to each field that failed validation.
+ *
+ * @covers FrmFormsHelper::get_invalid_error_message
+ */
+ public function test_get_invalid_error_message_builds_clickable_field_links() {
+ $this->form = $this->factory->form->create_and_get();
+
+ $text_id = $this->create_field_with_key( 'text', 'my_text' );
+ $select_id = $this->create_field_with_key( 'select', 'my_select' );
+ $checkbox_id = $this->create_field_with_key( 'checkbox', 'my_checkbox' );
+ $radio_id = $this->create_field_with_key( 'radio', 'my_radio' );
+
+ $message = FrmFormsHelper::get_invalid_error_message(
+ array(
+ 'form' => $this->form,
+ 'errors' => array(
+ 'field' . $text_id => 'Text is required',
+ 'field' . $select_id => 'Select is required',
+ 'field' . $checkbox_id => 'Checkbox is required',
+ 'field' . $radio_id => 'Radio is required',
+ ),
+ )
+ );
+
+ // The base invalid message is wrapped in a span, and the links are inside a list.
+ $this->assertStringContainsString( '', $message );
+
+ // Links target the field container, which every field type renders, rather than an input
+ // ID that only some field types have.
+ $this->assertStringContainsString( $this->error_link_html( $text_id, 'Text is required' ), $message );
+ $this->assertStringContainsString( $this->error_link_html( $select_id, 'Select is required' ), $message );
+ $this->assertStringContainsString( $this->error_link_html( $checkbox_id, 'Checkbox is required' ), $message );
+ $this->assertStringContainsString( $this->error_link_html( $radio_id, 'Radio is required' ), $message );
+ }
+
+ /**
+ * Field types that render no input matching the field key, or an input that cannot take focus,
+ * used to produce a link that went nowhere. Every type links to its container instead.
+ *
+ * @covers FrmFormsHelper::get_invalid_error_message
+ */
+ public function test_get_invalid_error_message_links_every_field_type() {
+ $this->form = $this->factory->form->create_and_get();
+
+ // The types reported as having broken links, plus the Lite types that always worked.
+ $types = array( 'file', 'name', 'address', 'time', 'star', 'scale', 'nps', 'gdpr', 'ranking', 'likert', 'text', 'email' );
+
+ $errors = array();
+ $field_ids = array();
+
+ foreach ( $types as $type ) {
+ $field_id = $this->create_field_with_key( $type, 'my_' . $type );
+ $field_ids[ $type ] = $field_id;
+ $errors[ 'field' . $field_id ] = ucfirst( $type ) . ' is required';
+ }
+
+ $message = FrmFormsHelper::get_invalid_error_message(
+ array(
+ 'form' => $this->form,
+ 'errors' => $errors,
+ )
+ );
+
+ foreach ( $types as $type ) {
+ $this->assertStringContainsString(
+ $this->error_link_html( $field_ids[ $type ], ucfirst( $type ) . ' is required' ),
+ $message,
+ 'The ' . $type . ' field should link to its container.'
+ );
+ }
+
+ // The old format guessed at an input ID from the field key. Nothing should use it anymore.
+ $this->assertStringNotContainsString( 'href="#field_my_', $message );
+ }
+
+ /**
+ * A combo field such as name or address reports an error per sub field, keyed
+ * field{id}-{sub_field}. Those keys have their own container to link to.
+ *
+ * @covers FrmFormsHelper::get_invalid_error_message
+ */
+ public function test_get_invalid_error_message_handles_combo_sub_field_keys() {
+ $this->form = $this->factory->form->create_and_get();
+ $name_id = $this->create_field_with_key( 'name', 'my_name' );
+
+ $message = FrmFormsHelper::get_invalid_error_message(
+ array(
+ 'form' => $this->form,
+ 'errors' => array(
+ 'field' . $name_id . '-first' => 'First name is required',
+ 'field' . $name_id . '-last' => 'Last name is required',
+ ),
+ )
+ );
+
+ $this->assertStringContainsString(
+ '- First name is required
',
+ $message
+ );
+ $this->assertStringContainsString(
+ '- Last name is required
',
+ $message
+ );
+ }
+
+ /**
+ * A combo sub field inside a repeater row (field{id}-{section_id}-{row}-{sub_field}) has no
+ * container of its own in the markup, so the link falls back to the row's field container.
+ *
+ * @covers FrmFormsHelper::get_invalid_error_message
+ */
+ public function test_get_invalid_error_message_handles_combo_sub_fields_in_repeater_rows() {
+ $this->form = $this->factory->form->create_and_get();
+ $name_id = $this->create_field_with_key( 'name', 'repeated_name' );
+
+ $message = FrmFormsHelper::get_invalid_error_message(
+ array(
+ 'form' => $this->form,
+ 'errors' => array(
+ 'field' . $name_id . '-55-2-first' => 'First name is required',
+ 'field' . $name_id . '-55-2-last' => 'Last name is required',
+ ),
+ )
+ );
+
+ $this->assertStringContainsString(
+ '- First name is required
',
+ $message
+ );
+ $this->assertStringContainsString(
+ '- Last name is required
',
+ $message
+ );
+ }
+
+ /**
+ * A combo field flags the sub field that failed with an empty error, as a marker for the input
+ * rather than a message to show. Those must not become empty links in the summary.
+ *
+ * @covers FrmFormsHelper::get_invalid_error_message
+ */
+ public function test_get_invalid_error_message_skips_errors_with_no_message() {
+ $this->form = $this->factory->form->create_and_get();
+ $name_id = $this->create_field_with_key( 'name', 'marker_name' );
+
+ $message = FrmFormsHelper::get_invalid_error_message(
+ array(
+ 'form' => $this->form,
+ 'errors' => array(
+ 'field' . $name_id . '-first' => '',
+ 'field' . $name_id . '-last' => ' ',
+ 'field' . $name_id => 'Name is required',
+ ),
+ )
+ );
+
+ $this->assertStringNotContainsString( '- assertSame( 1, substr_count( $message, '
- ' ) );
+ $this->assertStringContainsString( $this->error_link_html( $name_id, 'Name is required' ), $message );
+ }
+
+ /**
+ * The summary can be turned off entirely with a filter, leaving just the invalid message.
+ *
+ * @covers FrmFormsHelper::get_invalid_error_message
+ */
+ public function test_get_invalid_error_message_summary_can_be_filtered_off() {
+ $this->form = $this->factory->form->create_and_get();
+ $text_id = $this->create_field_with_key( 'text', 'filtered_text' );
+
+ add_filter( 'frm_show_clickable_field_errors', '__return_false' );
+
+ $message = FrmFormsHelper::get_invalid_error_message(
+ array(
+ 'form' => $this->form,
+ 'errors' => array(
+ 'field' . $text_id => 'Text is required',
+ ),
+ )
+ );
+
+ remove_filter( 'frm_show_clickable_field_errors', '__return_false' );
+
+ $this->assertStringNotContainsString( '
', $message );
+ $this->assertStringNotContainsString( 'Text is required', $message );
+ }
+
+ /**
+ * Non-field errors such as 'form' or 'spam' have no input to link to and should be skipped.
+ *
+ * @covers FrmFormsHelper::get_invalid_error_message
+ */
+ public function test_get_invalid_error_message_skips_non_field_errors() {
+ $this->form = $this->factory->form->create_and_get();
+ $text_id = $this->create_field_with_key( 'text', 'linkable_text' );
+
+ $message = FrmFormsHelper::get_invalid_error_message(
+ array(
+ 'form' => $this->form,
+ 'errors' => array(
+ 'form' => 'There was a problem with your submission.',
+ 'spam' => 'Your entry appears to be spam!',
+ 'field' . $text_id => 'Text is required',
+ ),
+ )
+ );
+
+ $this->assertStringContainsString( $this->error_link_html( $text_id, 'Text is required' ), $message );
+ $this->assertStringNotContainsString( 'appears to be spam', $message );
+ $this->assertStringNotContainsString( 'frm_field_spam', $message );
+ $this->assertStringNotContainsString( 'frm_field_form_container', $message );
+ }
+
+ /**
+ * With no field errors there should be no list at all, only the base message.
+ *
+ * @covers FrmFormsHelper::get_invalid_error_message
+ */
+ public function test_get_invalid_error_message_without_errors_has_no_list() {
+ $this->form = $this->factory->form->create_and_get();
+
+ $message = FrmFormsHelper::get_invalid_error_message( array( 'form' => $this->form ) );
+
+ $this->assertStringNotContainsString( '', $message );
+ $this->assertStringNotContainsString( '- ', $message );
+ }
+
+ /**
+ * Error messages may contain admin HTML, so unsafe markup is stripped while safe inline
+ * formatting is kept, and anchors are removed so they cannot nest inside the summary link.
+ *
+ * @covers FrmFormsHelper::get_invalid_error_message
+ */
+ public function test_get_invalid_error_message_sanitizes_error_html() {
+ $this->form = $this->factory->form->create_and_get();
+ $text_id = $this->create_field_with_key( 'text', 'escape_test' );
+
+ $message = FrmFormsHelper::get_invalid_error_message(
+ array(
+ 'form' => $this->form,
+ 'errors' => array(
+ 'field' . $text_id => 'Name is requiredx',
+ ),
+ )
+ );
+
+ // Scripts are removed entirely.
+ $this->assertStringNotContainsString( '