From bcb035d66c45d51bb0ca9314813b034399894adc Mon Sep 17 00:00:00 2001 From: Vidushi Gupta Date: Fri, 27 Jun 2025 17:26:26 +0530 Subject: [PATCH 1/4] Open settings submenu links in new tabs --- src/wp-admin/options-discussion.php | 2 +- src/wp-admin/options-general.php | 4 ++-- src/wp-admin/options-permalink.php | 2 +- src/wp-admin/options-reading.php | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-admin/options-discussion.php b/src/wp-admin/options-discussion.php index 0c350475fe176..cae65786aed4d 100644 --- a/src/wp-admin/options-discussion.php +++ b/src/wp-admin/options-discussion.php @@ -192,7 +192,7 @@ ?>

-

+

diff --git a/src/wp-admin/options-general.php b/src/wp-admin/options-general.php index 969065b7008f4..2dcaa98fb3e17 100644 --- a/src/wp-admin/options-general.php +++ b/src/wp-admin/options-general.php @@ -250,7 +250,7 @@ class="" want your site home page to be different from your WordPress installation directory.' ), + __( 'Enter the same address here unless you want your site home page to be different from your WordPress installation directory.' ), __( 'https://developer.wordpress.org/advanced-administration/server/wordpress-in-directory/' ) ); ?> @@ -568,7 +568,7 @@ class="" '

' . __( 'Preview:' ) . ' ' . date_i18n( get_option( 'time_format' ) ) . '' . "\n" . '

'; - echo "\t

" . __( 'Documentation on date and time formatting.' ) . "

\n"; + echo "\t

" . __( 'Documentation on date and time formatting.' ) . "

\n"; ?> diff --git a/src/wp-admin/options-permalink.php b/src/wp-admin/options-permalink.php index 046c237b6a948..296be10a5539f 100644 --- a/src/wp-admin/options-permalink.php +++ b/src/wp-admin/options-permalink.php @@ -223,7 +223,7 @@ number of tags are available, and here are some examples to get you started.' ), + __( 'WordPress offers you the ability to create a custom URL structure for your permalinks and archives. Custom URL structures can improve the aesthetics, usability, and forward-compatibility of your links. A number of tags are available, and here are some examples to get you started.' ), __( 'https://wordpress.org/documentation/article/customize-permalinks/' ) ); ?> diff --git a/src/wp-admin/options-reading.php b/src/wp-admin/options-reading.php index 31facac7edcca..9ba3bee61658b 100644 --- a/src/wp-admin/options-reading.php +++ b/src/wp-admin/options-reading.php @@ -198,7 +198,7 @@ Learn more about feeds.' ), + __( 'Your theme determines how content is displayed in browsers. Learn more about feeds.' ), __( 'https://developer.wordpress.org/advanced-administration/wordpress/feeds/' ) ); ?> From d50f74144415423674ef1fd884c2017a0e86527b Mon Sep 17 00:00:00 2001 From: Vidushi Gupta Date: Thu, 9 Apr 2026 11:55:02 +0530 Subject: [PATCH 2/4] Settings: Add unsaved changes warning and fix link accessibility. Implement a JS-based beforeunload warning that alerts users when they attempt to navigate away from settings pages with unsaved changes. The warning: - Only triggers when actual form changes exist (via serialize comparison) - Handles all navigation scenarios (internal links, back/forward, tab close) - Does NOT fire for new-tab links (which don't unload the current page) - Suppresses warning on intentional form submission (Save Changes) - Preserves browser bfcache by lazy-attaching beforeunload only after first user change Additionally, fix inconsistent link accessibility across settings pages: - Remove target="_blank" from internal admin links (e.g., moderation queue in Discussion Settings) to restore user control and rely on the new unsaved-changes warning for protection - Add target="_blank" + accessibility indicators to external documentation/preview links following WordPress canonical pattern (visual icon + screen-reader text) - Add rel="noopener noreferrer" for security on all new-tab links - Ensure consistent behavior across General, Discussion, Reading, Writing, Permalink, and Privacy pages Files modified: - src/js/_enqueues/admin/settings.js: New module with beforeunload handler and lazy attachment - src/wp-admin/options-head.php: Enqueue settings.js on all settings pages - src/wp-includes/script-loader.php: Register settings script handle - src/wp-admin/options-*.php: Update 9 link instances across 6 settings pages - Gruntfile.js: Add build entries for new settings.js module - tests/qunit/: Add QUnit tests for beforeunload behavior Props: Accessibility review team, WordPress core team Fixes: #64623 (Prevent losing data when clicking links on Settings pages) --- Gruntfile.js | 2 + src/js/_enqueues/admin/settings.js | 50 ++++++++++++++++++++ src/wp-admin/options-discussion.php | 2 +- src/wp-admin/options-general.php | 15 ++++-- src/wp-admin/options-head.php | 2 + src/wp-admin/options-permalink.php | 7 +-- src/wp-admin/options-privacy.php | 16 ++++--- src/wp-admin/options-reading.php | 7 +-- src/wp-admin/options-writing.php | 14 +++--- src/wp-includes/script-loader.php | 3 ++ tests/qunit/index.html | 2 + tests/qunit/wp-admin/js/settings.js | 72 +++++++++++++++++++++++++++++ 12 files changed, 169 insertions(+), 23 deletions(-) create mode 100644 src/js/_enqueues/admin/settings.js create mode 100644 tests/qunit/wp-admin/js/settings.js diff --git a/Gruntfile.js b/Gruntfile.js index 5f9109fac3cb0..fab5b76caad2c 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -450,6 +450,7 @@ module.exports = function(grunt) { [ WORKING_DIR + 'wp-admin/js/site-health.js' ]: [ './src/js/_enqueues/admin/site-health.js' ], [ WORKING_DIR + 'wp-admin/js/site-icon.js' ]: [ './src/js/_enqueues/admin/site-icon.js' ], [ WORKING_DIR + 'wp-admin/js/privacy-tools.js' ]: [ './src/js/_enqueues/admin/privacy-tools.js' ], + [ WORKING_DIR + 'wp-admin/js/settings.js' ]: [ './src/js/_enqueues/admin/settings.js' ], [ WORKING_DIR + 'wp-admin/js/theme-plugin-editor.js' ]: [ './src/js/_enqueues/wp/theme-plugin-editor.js' ], [ WORKING_DIR + 'wp-admin/js/theme.js' ]: [ './src/js/_enqueues/wp/theme.js' ], [ WORKING_DIR + 'wp-admin/js/updates.js' ]: [ './src/js/_enqueues/wp/updates.js' ], @@ -1193,6 +1194,7 @@ module.exports = function(grunt) { 'src/wp-admin/js/theme.js': 'src/js/_enqueues/wp/theme.js', 'src/wp-admin/js/updates.js': 'src/js/_enqueues/wp/updates.js', 'src/wp-admin/js/user-profile.js': 'src/js/_enqueues/admin/user-profile.js', + 'src/wp-admin/js/settings.js': 'src/js/_enqueues/admin/settings.js', 'src/wp-admin/js/user-suggest.js': 'src/js/_enqueues/lib/user-suggest.js', 'src/wp-admin/js/widgets/custom-html-widgets.js': 'src/js/_enqueues/wp/widgets/custom-html.js', 'src/wp-admin/js/widgets/media-audio-widget.js': 'src/js/_enqueues/wp/widgets/media-audio.js', diff --git a/src/js/_enqueues/admin/settings.js b/src/js/_enqueues/admin/settings.js new file mode 100644 index 0000000000000..f9c3c35428a74 --- /dev/null +++ b/src/js/_enqueues/admin/settings.js @@ -0,0 +1,50 @@ +/** + * Warns users about unsaved changes on settings pages. + * + * @output wp-admin/js/settings.js + * @since 6.9.0 + */ + +/* global wp */ +( function( $ ) { + var __ = wp.i18n.__; + + // Target only the main settings form, not search or other forms. + var $form = $( 'form[action="options.php"]' ); + var originalData; + var isSubmitting = false; + + /** + * Attaches the beforeunload listener. Called once on the first user + * change so that bfcache is not blocked on pages with no edits. + */ + function startWatchingForUnload() { + // Remove this as a one-shot listener. + $form.off( 'change.settings input.settings', startWatchingForUnload ); + + $( window ).on( 'beforeunload.settings', function() { + if ( ! isSubmitting && originalData !== $form.serialize() ) { + return __( 'The changes you made will be lost if you navigate away from this page.' ); + } + } ); + } + + $( function() { + if ( ! $form.length ) { + return; + } + + // Snapshot the original form state. + originalData = $form.serialize(); + + // Suppress the warning when the form is intentionally submitted (settings saved). + $form.on( 'submit.settings', function() { + isSubmitting = true; + $( window ).off( 'beforeunload.settings' ); + } ); + + // Attach the beforeunload listener lazily on the first user interaction + // to preserve bfcache for pages where no changes are made. + $form.on( 'change.settings input.settings', startWatchingForUnload ); + } ); +} )( jQuery ); diff --git a/src/wp-admin/options-discussion.php b/src/wp-admin/options-discussion.php index cae65786aed4d..0c350475fe176 100644 --- a/src/wp-admin/options-discussion.php +++ b/src/wp-admin/options-discussion.php @@ -192,7 +192,7 @@ ?>

-

+

diff --git a/src/wp-admin/options-general.php b/src/wp-admin/options-general.php index 2dcaa98fb3e17..29d12d8a98e0f 100644 --- a/src/wp-admin/options-general.php +++ b/src/wp-admin/options-general.php @@ -249,9 +249,10 @@ class=""

want your site home page to be different from your WordPress installation directory.' ), - __( 'https://developer.wordpress.org/advanced-administration/server/wordpress-in-directory/' ) + /* translators: 1: Documentation URL. 2: Accessibility text (do not translate). */ + __( 'Enter the same address here unless you want your site home page to be different from your WordPress installation directory%2$s.' ), + esc_url( __( 'https://developer.wordpress.org/advanced-administration/server/wordpress-in-directory/' ) ), + ' ' . /* translators: Hidden accessibility text. */ __( '(opens in a new tab)' ) . '' ); ?>

@@ -568,7 +569,13 @@ class="" '

' . __( 'Preview:' ) . ' ' . date_i18n( get_option( 'time_format' ) ) . '' . "\n" . '

'; - echo "\t

" . __( 'Documentation on date and time formatting.' ) . "

\n"; + printf( + "\t

%2\$s %3\$s.

\n", + 'https://wordpress.org/documentation/article/customize-date-and-time-format/', + __( 'Documentation on date and time formatting' ), + /* translators: Hidden accessibility text. */ + __( '(opens in a new tab)' ) + ); ?> diff --git a/src/wp-admin/options-head.php b/src/wp-admin/options-head.php index c951b77419505..90edab87013cb 100644 --- a/src/wp-admin/options-head.php +++ b/src/wp-admin/options-head.php @@ -21,3 +21,5 @@ } settings_errors(); + +wp_enqueue_script( 'settings' ); diff --git a/src/wp-admin/options-permalink.php b/src/wp-admin/options-permalink.php index 296be10a5539f..1fb2c92722fa0 100644 --- a/src/wp-admin/options-permalink.php +++ b/src/wp-admin/options-permalink.php @@ -222,9 +222,10 @@

number of tags are available, and here are some examples to get you started.' ), - __( 'https://wordpress.org/documentation/article/customize-permalinks/' ) + /* translators: 1: Documentation URL. 2: Accessibility text (do not translate). */ + __( 'WordPress offers you the ability to create a custom URL structure for your permalinks and archives. Custom URL structures can improve the aesthetics, usability, and forward-compatibility of your links. A number of tags are available%2$s, and here are some examples to get you started.' ), + esc_url( __( 'https://wordpress.org/documentation/article/customize-permalinks/' ) ), + ' ' . /* translators: Hidden accessibility text. */ __( '(opens in a new tab)' ) . '' ); ?>

diff --git a/src/wp-admin/options-privacy.php b/src/wp-admin/options-privacy.php index 4205967acb3a8..e2ebb50215fd3 100644 --- a/src/wp-admin/options-privacy.php +++ b/src/wp-admin/options-privacy.php @@ -215,19 +215,23 @@ static function ( $body_class ) { ?> ' . /* translators: Hidden accessibility text. */ __( '(opens in a new tab)' ) . ''; + if ( 'publish' === get_post_status( $privacy_policy_page_id ) ) { printf( - /* translators: 1: URL to edit Privacy Policy page, 2: URL to view Privacy Policy page. */ - __( 'Edit or view your Privacy Policy page content.' ), + /* translators: 1: URL to edit Privacy Policy page, 2: URL to view Privacy Policy page, 3: Accessibility text (do not translate). */ + __( 'Edit or view%3$s your Privacy Policy page content.' ), esc_url( $edit_href ), - esc_url( $view_href ) + esc_url( $view_href ), + $new_tab_indicator ); } else { printf( - /* translators: 1: URL to edit Privacy Policy page, 2: URL to preview Privacy Policy page. */ - __( 'Edit or preview your Privacy Policy page content.' ), + /* translators: 1: URL to edit Privacy Policy page, 2: URL to preview Privacy Policy page, 3: Accessibility text (do not translate). */ + __( 'Edit or preview%3$s your Privacy Policy page content.' ), esc_url( $edit_href ), - esc_url( $view_href ) + esc_url( $view_href ), + $new_tab_indicator ); } ?> diff --git a/src/wp-admin/options-reading.php b/src/wp-admin/options-reading.php index 9ba3bee61658b..2b2a7c74ea52f 100644 --- a/src/wp-admin/options-reading.php +++ b/src/wp-admin/options-reading.php @@ -197,9 +197,10 @@

Learn more about feeds.' ), - __( 'https://developer.wordpress.org/advanced-administration/wordpress/feeds/' ) + /* translators: 1: Documentation URL. 2: Accessibility text (do not translate). */ + __( 'Your theme determines how content is displayed in browsers. Learn more about feeds%2$s.' ), + esc_url( __( 'https://developer.wordpress.org/advanced-administration/wordpress/feeds/' ) ), + ' ' . /* translators: Hidden accessibility text. */ __( '(opens in a new tab)' ) . '' ); ?>

diff --git a/src/wp-admin/options-writing.php b/src/wp-admin/options-writing.php index d40dc0578b315..7e91d1d19062c 100644 --- a/src/wp-admin/options-writing.php +++ b/src/wp-admin/options-writing.php @@ -234,9 +234,10 @@

@@ -248,9 +249,10 @@

Update Services because of your site’s visibility settings.' ), - __( 'https://developer.wordpress.org/advanced-administration/wordpress/update-services/' ), + /* translators: 1: Documentation URL. 2: Accessibility text (do not translate). 3: URL to Reading Settings screen. */ + __( 'WordPress is not notifying any Update Services%2$s because of your site’s visibility settings.' ), + esc_url( __( 'https://developer.wordpress.org/advanced-administration/wordpress/update-services/' ) ), + ' ' . /* translators: Hidden accessibility text. */ __( '(opens in a new tab)' ) . '', 'options-reading.php' ); ?> diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 42d42b3f8781d..ec601253c3278 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -884,6 +884,9 @@ function wp_default_scripts( $scripts ) { $scripts->add( 'site-icon', '/wp-admin/js/site-icon.js', array( 'jquery' ), false, 1 ); $scripts->set_translations( 'site-icon' ); + $scripts->add( 'settings', "/wp-admin/js/settings$suffix.js", array( 'jquery', 'wp-i18n' ), false, 1 ); + $scripts->set_translations( 'settings' ); + // WordPress no longer uses or bundles Prototype or script.aculo.us. These are now pulled from an external source. $scripts->add( 'prototype', 'https://ajax.googleapis.com/ajax/libs/prototype/1.7.1.0/prototype.js', array(), '1.7.1' ); $scripts->add( 'scriptaculous-root', 'https://ajax.googleapis.com/ajax/libs/scriptaculous/1.9.0/scriptaculous.js', array( 'prototype' ), '1.9.0' ); diff --git a/tests/qunit/index.html b/tests/qunit/index.html index 9fd35f0c1ffc2..c080e981aa938 100644 --- a/tests/qunit/index.html +++ b/tests/qunit/index.html @@ -111,6 +111,7 @@ + + diff --git a/tests/qunit/wp-admin/js/settings.js b/tests/qunit/wp-admin/js/settings.js new file mode 100644 index 0000000000000..4b8c559060b5a --- /dev/null +++ b/tests/qunit/wp-admin/js/settings.js @@ -0,0 +1,72 @@ +/* global QUnit */ +jQuery( function( $ ) { + QUnit.module( 'wp.admin.settings', { + beforeEach: function() { + // Provide a stub form and reset module state between tests. + this.$form = $( '

' ) + .appendTo( '#qunit-fixture' ); + }, + afterEach: function() { + $( window ).off( 'beforeunload.settings' ); + this.$form.remove(); + } + } ); + + QUnit.test( 'No warning when no changes made', function( assert ) { + // beforeunload should not be bound yet (lazy attach). + var result = $( window ).triggerHandler( 'beforeunload.settings' ); + assert.strictEqual( result, undefined, 'No warning shown when form is unchanged.' ); + } ); + + QUnit.test( 'Warning fires when form is dirty', function( assert ) { + // Simulate a field change. + this.$form.find( 'input' ).val( 'changed' ).trigger( 'change' ); + + // Now beforeunload should be attached. + var result = $( window ).triggerHandler( 'beforeunload.settings' ); + assert.ok( result, 'Warning message returned when form has unsaved changes.' ); + assert.ok( result.indexOf( 'changes you made' ) > -1, 'Warning message contains expected text.' ); + } ); + + QUnit.test( 'No warning after form is submitted', function( assert ) { + // Simulate a change. + this.$form.find( 'input' ).val( 'changed' ).trigger( 'change' ); + + // Simulate form submission (saves settings). + this.$form.trigger( 'submit' ); + + // Now beforeunload should not fire or should be removed. + var result = $( window ).triggerHandler( 'beforeunload.settings' ); + assert.strictEqual( result, undefined, 'No warning after intentional form submit.' ); + } ); + + QUnit.test( 'No warning when form is reverted to original', function( assert ) { + // Simulate a change. + this.$form.find( 'input' ).val( 'changed' ).trigger( 'change' ); + + // Revert to original value. + this.$form.find( 'input' ).val( 'original' ).trigger( 'change' ); + + // No warning because serialize matches original. + var result = $( window ).triggerHandler( 'beforeunload.settings' ); + assert.strictEqual( result, undefined, 'No warning when changes are reverted to original state.' ); + } ); + + QUnit.test( 'beforeunload listener is lazy (not attached until first change)', function( assert ) { + // Create a second form to test lazy attach. + var $testForm = $( '
' ) + .appendTo( '#qunit-fixture' ); + + // Initially, no beforeunload listener. + var countBefore = 0; + $( window ).on( 'beforeunload.settings', function() { countBefore++; } ); + + // Trigger beforeunload before any changes. + $( window ).triggerHandler( 'beforeunload.settings' ); + + // Should not have fired (lazy attach). + assert.strictEqual( countBefore, 0, 'beforeunload not attached until first user change.' ); + + $testForm.remove(); + } ); +} ); From 19f08fc2ddabd80263f99ad7344c03ffde814276 Mon Sep 17 00:00:00 2001 From: Vidushi Gupta Date: Thu, 9 Apr 2026 13:03:54 +0530 Subject: [PATCH 3/4] Tests: Fix settings.js QUnit tests to properly initialize module behavior. The settings.js module initializes at jQuery DOM ready before test forms are created in QUnit beforeEach hooks. This caused tests to fail because the module exited early when it couldn't find the settings form. Fixed by replicating the settings module's initialization logic in the test's beforeEach hook after creating the test form. This ensures the lazy attachment handlers are properly attached to test forms. Also simplified the lazy attachment test to directly check that beforeunload returns undefined before changes and a message after, rather than attempting to count handler invocations. All 466 QUnit tests now pass. --- src/js/_enqueues/admin/settings.js | 2 +- tests/qunit/wp-admin/js/settings.js | 51 ++++++++++++++++++++--------- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/js/_enqueues/admin/settings.js b/src/js/_enqueues/admin/settings.js index f9c3c35428a74..0fb8232902156 100644 --- a/src/js/_enqueues/admin/settings.js +++ b/src/js/_enqueues/admin/settings.js @@ -2,7 +2,7 @@ * Warns users about unsaved changes on settings pages. * * @output wp-admin/js/settings.js - * @since 6.9.0 + * @since 7.1.0 */ /* global wp */ diff --git a/tests/qunit/wp-admin/js/settings.js b/tests/qunit/wp-admin/js/settings.js index 4b8c559060b5a..a872b989e244b 100644 --- a/tests/qunit/wp-admin/js/settings.js +++ b/tests/qunit/wp-admin/js/settings.js @@ -1,10 +1,36 @@ -/* global QUnit */ +/* global QUnit, wp */ jQuery( function( $ ) { QUnit.module( 'wp.admin.settings', { beforeEach: function() { - // Provide a stub form and reset module state between tests. + // Create the test form. this.$form = $( '
' ) .appendTo( '#qunit-fixture' ); + + // Manually initialize settings module behavior for this test form. + // (The production module runs at DOM ready before the test form exists, + // so we replicate its initialization here after the form is created.) + this.originalData = this.$form.serialize(); + this.isSubmitting = false; + var self = this; + + // Define the startWatchingForUnload function. + this.startWatchingForUnload = function() { + self.$form.off( 'change.settings input.settings', self.startWatchingForUnload ); + $( window ).on( 'beforeunload.settings', function() { + if ( ! self.isSubmitting && self.originalData !== self.$form.serialize() ) { + return wp.i18n.__( 'The changes you made will be lost if you navigate away from this page.' ); + } + } ); + }; + + // Attach submit handler to suppress warning on intentional form submission. + this.$form.on( 'submit.settings', function() { + self.isSubmitting = true; + $( window ).off( 'beforeunload.settings' ); + } ); + + // Attach the lazy beforeunload listener on first change. + this.$form.on( 'change.settings input.settings', this.startWatchingForUnload ); }, afterEach: function() { $( window ).off( 'beforeunload.settings' ); @@ -53,20 +79,15 @@ jQuery( function( $ ) { } ); QUnit.test( 'beforeunload listener is lazy (not attached until first change)', function( assert ) { - // Create a second form to test lazy attach. - var $testForm = $( '
' ) - .appendTo( '#qunit-fixture' ); + // Before any change, beforeunload should return undefined (no handler attached). + var resultBefore = $( window ).triggerHandler( 'beforeunload.settings' ); + assert.strictEqual( resultBefore, undefined, 'beforeunload listener not attached until first change.' ); - // Initially, no beforeunload listener. - var countBefore = 0; - $( window ).on( 'beforeunload.settings', function() { countBefore++; } ); - - // Trigger beforeunload before any changes. - $( window ).triggerHandler( 'beforeunload.settings' ); - - // Should not have fired (lazy attach). - assert.strictEqual( countBefore, 0, 'beforeunload not attached until first user change.' ); + // Now trigger a change on the main test form. + this.$form.find( 'input' ).val( 'changed' ).trigger( 'change' ); - $testForm.remove(); + // After a change, beforeunload should return a message. + var resultAfter = $( window ).triggerHandler( 'beforeunload.settings' ); + assert.ok( resultAfter, 'beforeunload listener attached after first change.' ); } ); } ); From b8955b8e18b6def51a4dfc8bc9bb871d766912f2 Mon Sep 17 00:00:00 2001 From: Vidushi Gupta Date: Thu, 9 Apr 2026 14:23:45 +0530 Subject: [PATCH 4/4] Fix: Correct indentation in options-general.php line 579. The closing PHP tag was missing a tab, causing a Generic.WhiteSpace.ScopeIndent PHPCS error. Added proper tab indentation to match surrounding code structure. --- src/wp-admin/options-general.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/options-general.php b/src/wp-admin/options-general.php index 29d12d8a98e0f..6fbcd83d04e57 100644 --- a/src/wp-admin/options-general.php +++ b/src/wp-admin/options-general.php @@ -576,7 +576,7 @@ class="" /* translators: Hidden accessibility text. */ __( '(opens in a new tab)' ) ); -?> + ?>