Skip to content

Commit 40edf68

Browse files
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)
1 parent bcb035d commit 40edf68

12 files changed

Lines changed: 169 additions & 23 deletions

File tree

Gruntfile.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,7 @@ module.exports = function(grunt) {
450450
[ WORKING_DIR + 'wp-admin/js/site-health.js' ]: [ './src/js/_enqueues/admin/site-health.js' ],
451451
[ WORKING_DIR + 'wp-admin/js/site-icon.js' ]: [ './src/js/_enqueues/admin/site-icon.js' ],
452452
[ WORKING_DIR + 'wp-admin/js/privacy-tools.js' ]: [ './src/js/_enqueues/admin/privacy-tools.js' ],
453+
[ WORKING_DIR + 'wp-admin/js/settings.js' ]: [ './src/js/_enqueues/admin/settings.js' ],
453454
[ WORKING_DIR + 'wp-admin/js/theme-plugin-editor.js' ]: [ './src/js/_enqueues/wp/theme-plugin-editor.js' ],
454455
[ WORKING_DIR + 'wp-admin/js/theme.js' ]: [ './src/js/_enqueues/wp/theme.js' ],
455456
[ WORKING_DIR + 'wp-admin/js/updates.js' ]: [ './src/js/_enqueues/wp/updates.js' ],
@@ -1193,6 +1194,7 @@ module.exports = function(grunt) {
11931194
'src/wp-admin/js/theme.js': 'src/js/_enqueues/wp/theme.js',
11941195
'src/wp-admin/js/updates.js': 'src/js/_enqueues/wp/updates.js',
11951196
'src/wp-admin/js/user-profile.js': 'src/js/_enqueues/admin/user-profile.js',
1197+
'src/wp-admin/js/settings.js': 'src/js/_enqueues/admin/settings.js',
11961198
'src/wp-admin/js/user-suggest.js': 'src/js/_enqueues/lib/user-suggest.js',
11971199
'src/wp-admin/js/widgets/custom-html-widgets.js': 'src/js/_enqueues/wp/widgets/custom-html.js',
11981200
'src/wp-admin/js/widgets/media-audio-widget.js': 'src/js/_enqueues/wp/widgets/media-audio.js',

src/js/_enqueues/admin/settings.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Warns users about unsaved changes on settings pages.
3+
*
4+
* @output wp-admin/js/settings.js
5+
* @since 6.9.0
6+
*/
7+
8+
/* global wp */
9+
( function( $ ) {
10+
var __ = wp.i18n.__;
11+
12+
// Target only the main settings form, not search or other forms.
13+
var $form = $( 'form[action="options.php"]' );
14+
var originalData;
15+
var isSubmitting = false;
16+
17+
/**
18+
* Attaches the beforeunload listener. Called once on the first user
19+
* change so that bfcache is not blocked on pages with no edits.
20+
*/
21+
function startWatchingForUnload() {
22+
// Remove this as a one-shot listener.
23+
$form.off( 'change.settings input.settings', startWatchingForUnload );
24+
25+
$( window ).on( 'beforeunload.settings', function() {
26+
if ( ! isSubmitting && originalData !== $form.serialize() ) {
27+
return __( 'The changes you made will be lost if you navigate away from this page.' );
28+
}
29+
} );
30+
}
31+
32+
$( function() {
33+
if ( ! $form.length ) {
34+
return;
35+
}
36+
37+
// Snapshot the original form state.
38+
originalData = $form.serialize();
39+
40+
// Suppress the warning when the form is intentionally submitted (settings saved).
41+
$form.on( 'submit.settings', function() {
42+
isSubmitting = true;
43+
$( window ).off( 'beforeunload.settings' );
44+
} );
45+
46+
// Attach the beforeunload listener lazily on the first user interaction
47+
// to preserve bfcache for pages where no changes are made.
48+
$form.on( 'change.settings input.settings', startWatchingForUnload );
49+
} );
50+
} )( jQuery );

src/wp-admin/options-discussion.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@
192192
?>
193193
</label></p>
194194

195-
<p><label for="moderation_keys"><?php _e( 'When a comment contains any of these words in its content, author name, URL, email, IP address, or browser&#8217;s user agent string, it will be held in the <a href="edit-comments.php?comment_status=moderated" target="_blank">moderation queue</a>. One word or IP address per line. It will match inside words, so &#8220;press&#8221; will match &#8220;WordPress&#8221;.' ); ?></label></p>
195+
<p><label for="moderation_keys"><?php _e( 'When a comment contains any of these words in its content, author name, URL, email, IP address, or browser&#8217;s user agent string, it will be held in the <a href="edit-comments.php?comment_status=moderated">moderation queue</a>. One word or IP address per line. It will match inside words, so &#8220;press&#8221; will match &#8220;WordPress&#8221;.' ); ?></label></p>
196196
<p>
197197
<textarea name="moderation_keys" rows="10" cols="50" id="moderation_keys" class="large-text code"><?php echo esc_textarea( get_option( 'moderation_keys' ) ); ?></textarea>
198198
</p>

src/wp-admin/options-general.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,9 +249,10 @@ class="<?php echo esc_attr( $classes_for_button ); ?>"
249249
<p class="description" id="home-description">
250250
<?php
251251
printf(
252-
/* translators: %s: Documentation URL. */
253-
__( 'Enter the same address here unless you <a href="%s" target=_blank>want your site home page to be different from your WordPress installation directory</a>.' ),
254-
__( 'https://developer.wordpress.org/advanced-administration/server/wordpress-in-directory/' )
252+
/* translators: 1: Documentation URL. 2: Accessibility text (do not translate). */
253+
__( 'Enter the same address here unless you <a href="%1$s" target="_blank" rel="noopener noreferrer">want your site home page to be different from your WordPress installation directory%2$s</a>.' ),
254+
esc_url( __( 'https://developer.wordpress.org/advanced-administration/server/wordpress-in-directory/' ) ),
255+
'<span class="screen-reader-text"> ' . /* translators: Hidden accessibility text. */ __( '(opens in a new tab)' ) . '</span><span aria-hidden="true" class="dashicons dashicons-external"></span>'
255256
);
256257
?>
257258
</p>
@@ -568,7 +569,13 @@ class="<?php echo esc_attr( $classes_for_button ); ?>"
568569
'<p><strong>' . __( 'Preview:' ) . '</strong> <span class="example">' . date_i18n( get_option( 'time_format' ) ) . '</span>' .
569570
"<span class='spinner'></span>\n" . '</p>';
570571

571-
echo "\t<p class='date-time-doc'>" . __( '<a href="https://wordpress.org/documentation/article/customize-date-and-time-format/" target="_blank">Documentation on date and time formatting</a>.' ) . "</p>\n";
572+
printf(
573+
"\t<p class='date-time-doc'><a href=\"%1\$s\" target=\"_blank\" rel=\"noopener noreferrer\">%2\$s<span class=\"screen-reader-text\"> %3\$s</span><span aria-hidden=\"true\" class=\"dashicons dashicons-external\"></span></a>.</p>\n",
574+
'https://wordpress.org/documentation/article/customize-date-and-time-format/',
575+
__( 'Documentation on date and time formatting' ),
576+
/* translators: Hidden accessibility text. */
577+
__( '(opens in a new tab)' )
578+
);
572579
?>
573580
</fieldset>
574581
</td>

src/wp-admin/options-head.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@
2121
}
2222

2323
settings_errors();
24+
25+
wp_enqueue_script( 'settings' );

src/wp-admin/options-permalink.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,10 @@
222222
<p>
223223
<?php
224224
printf(
225-
/* translators: %s: Documentation URL. */
226-
__( '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 <a href="%s" target="_blank">number of tags are available</a>, and here are some examples to get you started.' ),
227-
__( 'https://wordpress.org/documentation/article/customize-permalinks/' )
225+
/* translators: 1: Documentation URL. 2: Accessibility text (do not translate). */
226+
__( '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 <a href="%1$s" target="_blank" rel="noopener noreferrer">number of tags are available%2$s</a>, and here are some examples to get you started.' ),
227+
esc_url( __( 'https://wordpress.org/documentation/article/customize-permalinks/' ) ),
228+
'<span class="screen-reader-text"> ' . /* translators: Hidden accessibility text. */ __( '(opens in a new tab)' ) . '</span><span aria-hidden="true" class="dashicons dashicons-external"></span>'
228229
);
229230
?>
230231
</p>

src/wp-admin/options-privacy.php

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -215,19 +215,23 @@ static function ( $body_class ) {
215215
?>
216216
<strong>
217217
<?php
218+
$new_tab_indicator = '<span class="screen-reader-text"> ' . /* translators: Hidden accessibility text. */ __( '(opens in a new tab)' ) . '</span><span aria-hidden="true" class="dashicons dashicons-external"></span>';
219+
218220
if ( 'publish' === get_post_status( $privacy_policy_page_id ) ) {
219221
printf(
220-
/* translators: 1: URL to edit Privacy Policy page, 2: URL to view Privacy Policy page. */
221-
__( '<a href="%1$s">Edit</a> or <a href="%2$s">view</a> your Privacy Policy page content.' ),
222+
/* translators: 1: URL to edit Privacy Policy page, 2: URL to view Privacy Policy page, 3: Accessibility text (do not translate). */
223+
__( '<a href="%1$s">Edit</a> or <a href="%2$s" target="_blank" rel="noopener noreferrer">view%3$s</a> your Privacy Policy page content.' ),
222224
esc_url( $edit_href ),
223-
esc_url( $view_href )
225+
esc_url( $view_href ),
226+
$new_tab_indicator
224227
);
225228
} else {
226229
printf(
227-
/* translators: 1: URL to edit Privacy Policy page, 2: URL to preview Privacy Policy page. */
228-
__( '<a href="%1$s">Edit</a> or <a href="%2$s">preview</a> your Privacy Policy page content.' ),
230+
/* translators: 1: URL to edit Privacy Policy page, 2: URL to preview Privacy Policy page, 3: Accessibility text (do not translate). */
231+
__( '<a href="%1$s">Edit</a> or <a href="%2$s" target="_blank" rel="noopener noreferrer">preview%3$s</a> your Privacy Policy page content.' ),
229232
esc_url( $edit_href ),
230-
esc_url( $view_href )
233+
esc_url( $view_href ),
234+
$new_tab_indicator
231235
);
232236
}
233237
?>

src/wp-admin/options-reading.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,10 @@
197197
<p class="description">
198198
<?php
199199
printf(
200-
/* translators: %s: Documentation URL. */
201-
__( 'Your theme determines how content is displayed in browsers. <a href="%s" target="_blank">Learn more about feeds</a>.' ),
202-
__( 'https://developer.wordpress.org/advanced-administration/wordpress/feeds/' )
200+
/* translators: 1: Documentation URL. 2: Accessibility text (do not translate). */
201+
__( 'Your theme determines how content is displayed in browsers. <a href="%1$s" target="_blank" rel="noopener noreferrer">Learn more about feeds%2$s</a>.' ),
202+
esc_url( __( 'https://developer.wordpress.org/advanced-administration/wordpress/feeds/' ) ),
203+
'<span class="screen-reader-text"> ' . /* translators: Hidden accessibility text. */ __( '(opens in a new tab)' ) . '</span><span aria-hidden="true" class="dashicons dashicons-external"></span>'
203204
);
204205
?>
205206
</p>

src/wp-admin/options-writing.php

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,10 @@
234234
<p><label for="ping_sites">
235235
<?php
236236
printf(
237-
/* translators: %s: Documentation URL. */
238-
__( 'When you publish a new post, WordPress automatically notifies the following site update services. For more about this, see the <a href="%s">Update Services</a> documentation article. Separate multiple service URLs with line breaks.' ),
239-
__( 'https://developer.wordpress.org/advanced-administration/wordpress/update-services/' )
237+
/* translators: 1: Documentation URL. 2: Accessibility text (do not translate). */
238+
__( 'When you publish a new post, WordPress automatically notifies the following site update services. For more about this, see the <a href="%1$s" target="_blank" rel="noopener noreferrer">Update Services%2$s</a> documentation article. Separate multiple service URLs with line breaks.' ),
239+
esc_url( __( 'https://developer.wordpress.org/advanced-administration/wordpress/update-services/' ) ),
240+
'<span class="screen-reader-text"> ' . /* translators: Hidden accessibility text. */ __( '(opens in a new tab)' ) . '</span><span aria-hidden="true" class="dashicons dashicons-external"></span>'
240241
);
241242
?>
242243
</label></p>
@@ -248,9 +249,10 @@
248249
<p>
249250
<?php
250251
printf(
251-
/* translators: 1: Documentation URL, 2: URL to Reading Settings screen. */
252-
__( 'WordPress is not notifying any <a href="%1$s">Update Services</a> because of your site&#8217;s <a href="%2$s">visibility settings</a>.' ),
253-
__( 'https://developer.wordpress.org/advanced-administration/wordpress/update-services/' ),
252+
/* translators: 1: Documentation URL. 2: Accessibility text (do not translate). 3: URL to Reading Settings screen. */
253+
__( 'WordPress is not notifying any <a href="%1$s" target="_blank" rel="noopener noreferrer">Update Services%2$s</a> because of your site&#8217;s <a href="%3$s">visibility settings</a>.' ),
254+
esc_url( __( 'https://developer.wordpress.org/advanced-administration/wordpress/update-services/' ) ),
255+
'<span class="screen-reader-text"> ' . /* translators: Hidden accessibility text. */ __( '(opens in a new tab)' ) . '</span><span aria-hidden="true" class="dashicons dashicons-external"></span>',
254256
'options-reading.php'
255257
);
256258
?>

src/wp-includes/script-loader.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,9 @@ function wp_default_scripts( $scripts ) {
884884
$scripts->add( 'site-icon', '/wp-admin/js/site-icon.js', array( 'jquery' ), false, 1 );
885885
$scripts->set_translations( 'site-icon' );
886886

887+
$scripts->add( 'settings', "/wp-admin/js/settings$suffix.js", array( 'jquery', 'wp-i18n' ), false, 1 );
888+
$scripts->set_translations( 'settings' );
889+
887890
// WordPress no longer uses or bundles Prototype or script.aculo.us. These are now pulled from an external source.
888891
$scripts->add( 'prototype', 'https://ajax.googleapis.com/ajax/libs/prototype/1.7.1.0/prototype.js', array(), '1.7.1' );
889892
$scripts->add( 'scriptaculous-root', 'https://ajax.googleapis.com/ajax/libs/scriptaculous/1.9.0/scriptaculous.js', array( 'prototype' ), '1.9.0' );

0 commit comments

Comments
 (0)