From a6336015121971756764e6c4ebf01624ac2c26f5 Mon Sep 17 00:00:00 2001 From: "vivi-the-going-merry[bot]" <308115520+vivi-the-going-merry[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:47:34 -0600 Subject: [PATCH 1/2] Focus success message after AJAX submit Screen reader users get no notification when the success/error message replaces the form after an AJAX submit, since focus stays wherever it was before the submit button was clicked. - tabindex="-1" on the message div (FrmFormsHelper::get_success_message) makes it programmatically focusable without adding it to the tab order. - js/formidable.js focuses the message once it's live in the document. The old .frm_forms wrapper is already detached by the time the message needs to be looked up, so it's queried fresh from the still-attached parent captured before replaceWith() ran, not from the replaced-away subtree. Closes Strategy11/formidable-pro#6702 --- classes/helpers/FrmFormsHelper.php | 4 +++- js/formidable.js | 21 +++++++++++++++++++++ tests/cypress/e2e/admin.cy.js | 3 +++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/classes/helpers/FrmFormsHelper.php b/classes/helpers/FrmFormsHelper.php index b9da117e58..97671eceec 100644 --- a/classes/helpers/FrmFormsHelper.php +++ b/classes/helpers/FrmFormsHelper.php @@ -532,7 +532,9 @@ public static function get_success_message( $atts ) { $message = do_shortcode( $message ); $role = $atts['role'] ?? 'status'; - return '
' . $message . '
'; + // tabindex="-1" makes the message programmatically focusable without adding it to the tab order, + // so it can receive focus after an AJAX submit without a screen reader user having to tab to it. + return '
' . $message . '
'; } /** diff --git a/js/formidable.js b/js/formidable.js index 41c018a4bd..5fbc4180c2 100644 --- a/js/formidable.js +++ b/js/formidable.js @@ -939,12 +939,14 @@ function frmFrontFormJS() { const replaceContent = jQuery( object ).closest( '.frm_forms' ); // eslint-disable-line no-jquery/no-closest removeAddedScripts( replaceContent, formID ); const delay = maybeSlideOut( replaceContent, response.content ); + const formParent = replaceContent.parent(); setTimeout( function() { afterFormSubmittedBeforeReplace( object, response ); replaceContent.replaceWith( response.content ); + focusFormMessage( formParent ); addUrlParam( response ); @@ -1070,6 +1072,25 @@ function frmFrontFormJS() { jQuery.ajax( ajaxParams ); // eslint-disable-line no-jquery/no-ajax } + /** + * Move focus to the top-level success message after an AJAX submit, so screen reader + * users are notified it appeared. `replaceContent` (the old `.frm_forms` wrapper) is + * already detached from the document by the time this runs, so the message has to be + * looked up fresh from `formParent` (still live in the document) rather than from the + * replaced-away subtree or from a detached `tempDiv` copy of `response.content`. + * + * @since x.x + * + * @param {jQuery} formParent The element that contained the form before it was replaced. + * @return {void} + */ + function focusFormMessage( formParent ) { + const message = formParent[ 0 ] && formParent[ 0 ].querySelector( '.frm_message' ); + if ( message ) { + message.focus(); + } + } + function afterFormSubmitted( object, response ) { const tempDiv = document.createElement( 'div' ); tempDiv.innerHTML = response.content; diff --git a/tests/cypress/e2e/admin.cy.js b/tests/cypress/e2e/admin.cy.js index 80941a8a93..e25df9e43e 100644 --- a/tests/cypress/e2e/admin.cy.js +++ b/tests/cypress/e2e/admin.cy.js @@ -38,6 +38,9 @@ describe( 'Run some basic Formidale tests', function() { cy.visit( `/wp-admin/admin-ajax.php?action=frm_forms_preview&form=${ formKey }` ); cy.get( '.frm_button_submit' ).should( 'contain.text', 'Submit' ).click(); cy.get( '.frm_message' ).should( 'contain.text', 'Your responses were successfully submitted. Thank you!' ); + + // Focus should move to the success message so screen reader users are notified it appeared. + cy.focused().should( 'have.class', 'frm_message' ); } ); } ); } ); From 4281096655d97e1a4d77ac155072d165728bcb72 Mon Sep 17 00:00:00 2001 From: "vivi-the-going-merry[bot]" <308115520+vivi-the-going-merry[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:53:42 -0600 Subject: [PATCH 2/2] Self-review: reuse focusInput(), scope focus lookup to inserted content - Reuse the existing focusInput() helper (already used by the error-summary a11y work) instead of a bare .focus() call, so a message that appears mid slide-in animation is handled the same way error focus already is. - Scope the .frm_message lookup to the markup that was just inserted, not the form's whole parent container, so an unrelated element elsewhere on the page can never be focused instead. - Tighten the Cypress assertion to check focus on the message element itself, and the PHP comment to not overclaim beyond the success case. --- classes/helpers/FrmFormsHelper.php | 5 +++-- js/formidable.js | 21 ++++++++++----------- tests/cypress/e2e/admin.cy.js | 5 +++-- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/classes/helpers/FrmFormsHelper.php b/classes/helpers/FrmFormsHelper.php index 97671eceec..d858d58bc1 100644 --- a/classes/helpers/FrmFormsHelper.php +++ b/classes/helpers/FrmFormsHelper.php @@ -532,8 +532,9 @@ public static function get_success_message( $atts ) { $message = do_shortcode( $message ); $role = $atts['role'] ?? 'status'; - // tabindex="-1" makes the message programmatically focusable without adding it to the tab order, - // so it can receive focus after an AJAX submit without a screen reader user having to tab to it. + // tabindex="-1" makes the success message programmatically focusable without adding it + // to the tab order, so js/formidable.js can focus it after an AJAX submit without a + // screen reader user having to tab to it. return '
' . $message . '
'; } diff --git a/js/formidable.js b/js/formidable.js index 5fbc4180c2..4b57e6a1b4 100644 --- a/js/formidable.js +++ b/js/formidable.js @@ -939,14 +939,14 @@ function frmFrontFormJS() { const replaceContent = jQuery( object ).closest( '.frm_forms' ); // eslint-disable-line no-jquery/no-closest removeAddedScripts( replaceContent, formID ); const delay = maybeSlideOut( replaceContent, response.content ); - const formParent = replaceContent.parent(); setTimeout( function() { afterFormSubmittedBeforeReplace( object, response ); - replaceContent.replaceWith( response.content ); - focusFormMessage( formParent ); + const insertedContent = jQuery( response.content ); + replaceContent.replaceWith( insertedContent ); + focusFormMessage( insertedContent ); addUrlParam( response ); @@ -1074,20 +1074,19 @@ function frmFrontFormJS() { /** * Move focus to the top-level success message after an AJAX submit, so screen reader - * users are notified it appeared. `replaceContent` (the old `.frm_forms` wrapper) is - * already detached from the document by the time this runs, so the message has to be - * looked up fresh from `formParent` (still live in the document) rather than from the - * replaced-away subtree or from a detached `tempDiv` copy of `response.content`. + * users are notified it appeared. Scoped to `insertedContent` (the markup that just + * replaced the form) rather than a wider ancestor, so an unrelated `.frm_message`-classed + * element elsewhere on the page can never be focused instead. * * @since x.x * - * @param {jQuery} formParent The element that contained the form before it was replaced. + * @param {jQuery} insertedContent The markup that just replaced the form. * @return {void} */ - function focusFormMessage( formParent ) { - const message = formParent[ 0 ] && formParent[ 0 ].querySelector( '.frm_message' ); + function focusFormMessage( insertedContent ) { + const message = insertedContent.filter( '.frm_message' ).add( insertedContent.find( '.frm_message' ) ).get( 0 ); if ( message ) { - message.focus(); + focusInput( message ); } } diff --git a/tests/cypress/e2e/admin.cy.js b/tests/cypress/e2e/admin.cy.js index e25df9e43e..f5557f80fa 100644 --- a/tests/cypress/e2e/admin.cy.js +++ b/tests/cypress/e2e/admin.cy.js @@ -37,10 +37,11 @@ describe( 'Run some basic Formidale tests', function() { cy.get( '#frm_form_key' ).invoke( 'val' ).then( formKey => { cy.visit( `/wp-admin/admin-ajax.php?action=frm_forms_preview&form=${ formKey }` ); cy.get( '.frm_button_submit' ).should( 'contain.text', 'Submit' ).click(); - cy.get( '.frm_message' ).should( 'contain.text', 'Your responses were successfully submitted. Thank you!' ); // Focus should move to the success message so screen reader users are notified it appeared. - cy.focused().should( 'have.class', 'frm_message' ); + cy.get( '.frm_message' ) + .should( 'contain.text', 'Your responses were successfully submitted. Thank you!' ) + .and( 'be.focused' ); } ); } ); } );