Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
eb6df97
Improve form errors accessibility
AbdiTolesa Aug 8, 2025
88c9434
Move logic that creates field error messages with links to a new func…
AbdiTolesa Aug 8, 2025
924ccf8
Fix PHPCS errpr
AbdiTolesa Aug 8, 2025
d79ac34
Fix equal sign alignment
AbdiTolesa Aug 8, 2025
ce3144c
Add comment to function
AbdiTolesa Aug 8, 2025
e59c9c4
Merge branch 'master' into issue-5886-improve_form_errors_accessibility
Crabcyborg Dec 3, 2025
e7df410
Add CSS rules for form errors list
AbdiTolesa Dec 5, 2025
5ef4847
Merge branches 'issue-5886-improve_form_errors_accessibility' and 'is…
AbdiTolesa Dec 5, 2025
ba68f4a
Fix PHP CS errors
AbdiTolesa Dec 5, 2025
401b972
Make code more readable
AbdiTolesa Dec 5, 2025
4a03c7d
Fix PHP CS errors
AbdiTolesa Dec 5, 2025
b2603a4
Show field errors summary for ajax submit
AbdiTolesa Dec 5, 2025
4d613a9
Merge branch 'master' into issue-5886-improve_form_errors_accessibility
Crabcyborg Jun 1, 2026
7fd9665
Merge branch 'master' into issue-5886-improve_form_errors_accessibility
Crabcyborg Aug 5, 2026
b28445d
Issue 5886 improvements
Crabcyborg Aug 5, 2026
c24a1da
Use role="alert" for AJAX errors, use kses on error message
Crabcyborg Aug 5, 2026
4a6ff79
Avoid linking to a field that is not on page or visible
Crabcyborg Aug 5, 2026
6e92a72
Fix some failing workflow issues
Crabcyborg Aug 5, 2026
e3d6ef9
Shift some code
Crabcyborg Aug 5, 2026
c1c2719
Run php cs fixer on stub
Crabcyborg Aug 5, 2026
62490e8
Fix margin bottom issue in ul
Crabcyborg Aug 5, 2026
cae5029
Merge pull request #3221 from Strategy11/issue_5886_improvements
Crabcyborg Aug 5, 2026
7652ec3
Possibly avoid a DB query
Crabcyborg Aug 5, 2026
7e31ce9
Remove test code
Crabcyborg Aug 5, 2026
6df5de4
Further improve styling (remove left indentation
Crabcyborg Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion classes/controllers/FrmEntriesAJAXSubmitController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
184 changes: 181 additions & 3 deletions classes/helpers/FrmFormsHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,184 @@ 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 = '<span>' . do_shortcode( $frm_settings->invalid_msg ) . '</span>';

if ( $field_error_messages ) {
$invalid_msg .= "<ul>$field_error_messages</ul>";
}

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 '';
}

// Parse each error key once into its field ID and repeater row suffix, 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 ) {
$field_id = preg_replace( '/^field/', '', $field_plus_id );
$row = '';

if ( str_contains( $field_id, '-' ) ) {
$field_id_parts = explode( '-', $field_id );

if ( count( $field_id_parts ) === 3 ) {
$field_id = $field_id_parts[0];
$row = '-' . $field_id_parts[2];
}
}

if ( ! is_numeric( $field_id ) ) {
continue;
}

$field_ids[] = (int) $field_id;
$parsed_errors[] = compact( 'field_id', 'row', 'error' );
}

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 .= '<li>' . $error . '</li>';
continue;
}

$html_id = 'field_' . $field->field_key . $parsed_error['row'];

if ( in_array( $field->type, array( 'checkbox', 'radio' ), true ) ) {
// Needed to focus on the first option when the error link is clicked.
$html_id .= '-0';
}

$field_error_messages .= '<li><a href="#' . esc_attr( $html_id ) . '">' . $error . '</a></li>';
}//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.
Expand All @@ -301,6 +474,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
Expand Down Expand Up @@ -328,7 +504,9 @@ public static function get_success_message( $atts ) {
}

$message = do_shortcode( $message );
return '<div class="' . esc_attr( $atts['class'] ) . '" role="status">' . $message . '</div>';
$role = $atts['role'] ?? 'status';

return '<div class="' . esc_attr( $atts['class'] ) . '" role="' . esc_attr( $role ) . '">' . $message . '</div>';
}

/**
Expand Down
9 changes: 8 additions & 1 deletion classes/views/frm-entries/errors.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );

?>
</div>
Expand Down
20 changes: 20 additions & 0 deletions css/_single_theme.css.php
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,26 @@
margin-bottom:var(--field-margin);
}

.<?php echo esc_html( $style_class ); ?> .frm_error_style span{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable $style_class might not be defined


A variable has been used but not defined, which may result in warnings during program execution. This can also cause bugs since the intended usage scope of the variable is not known.

font-weight: bold<?php echo esc_html( $important ); ?>;
}

.<?php echo esc_html( $style_class ); ?> .frm_error_style ul{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable $style_class might not be defined


A variable has been used but not defined, which may result in warnings during program execution. This can also cause bugs since the intended usage scope of the variable is not known.

list-style: inside<?php echo esc_html( $important ); ?>;
color: var(--error-text)<?php echo esc_html( $important ); ?>;
margin-bottom: 0<?php echo esc_html( $important ); ?>;
margin-left: 0;
list-style-position: outside;
}

.<?php echo esc_html( $style_class ); ?> .frm_error_style ul li a{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable $style_class might not be defined


A variable has been used but not defined, which may result in warnings during program execution. This can also cause bugs since the intended usage scope of the variable is not known.

color: var(--error-text)<?php echo esc_html( $important ); ?>;
}

.<?php echo esc_html( $style_class ); ?> .frm_error_style ul li a:hover{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable $style_class might not be defined


A variable has been used but not defined, which may result in warnings during program execution. This can also cause bugs since the intended usage scope of the variable is not known.

text-decoration: underline<?php echo esc_html( $important ); ?>;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<?php if ( $pro_is_installed ) { ?>
.<?php echo esc_html( $style_class ); ?> #frm_loading .progress-striped .progress-bar{
background-image:linear-gradient(45deg, <?php echo esc_html( $border_color ); ?> 25%, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 0) 50%, <?php echo esc_html( $border_color ); ?> 50%, <?php echo esc_html( $border_color ); ?> 75%, rgba(0, 0, 0, 0) 75%, rgba(0, 0, 0, 0));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable $border_color might not be defined


A variable has been used but not defined, which may result in warnings during program execution. This can also cause bugs since the intended usage scope of the variable is not known.

Expand Down
7 changes: 7 additions & 0 deletions stubs.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method FrmProFieldsHelper::field_on_current_page() should return bool but return statement is missing


This issue is raised if a method with a return type does not have a return statement of an appropriate type.

}
}
class FrmViewsAppHelper {
/**
Expand Down
Loading
Loading