Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
342f997
Update heading
ocean90 Jul 15, 2025
58b709f
Remove default selected option
ocean90 Jul 15, 2025
fcb23c1
Typecast allusers parameter and reuse variable
ocean90 Jul 15, 2025
6328326
Add TODO
ocean90 Jul 15, 2025
abc9cc9
Update variable
ocean90 Jul 15, 2025
1ac17bd
Exclude user to delete from reassignment dropdown
ocean90 Jul 15, 2025
908a25c
Add ID to submit button
ocean90 Jul 15, 2025
c2b7fbd
Add TODO
ocean90 Jul 15, 2025
b366373
Disable button if nothing is selected
ocean90 Jul 15, 2025
1d4cd34
Use wp_dropdown_users()
ocean90 Jul 15, 2025
87143da
Use required attribute
ocean90 Jul 15, 2025
4e057d0
Refactor user deletion form, improve UI.
derpaschi Sep 16, 2025
afe1012
Implement logic to disable submit button until all radio buttons are …
derpaschi Oct 14, 2025
9d5385d
Check if form exists
ocean90 Nov 11, 2025
0add334
Refactor IIFE in common.js
ocean90 Nov 11, 2025
4fbfce2
Removed unused function parameter
derpaschi Nov 11, 2025
271ca0a
Update radio group selection in form validation
derpaschi Nov 11, 2025
c3e3a89
When deleting users, check if user has content, otherwise hide attrib…
derpaschi Nov 11, 2025
a2798e8
Remove duplicate filter documentation
ocean90 Nov 11, 2025
ef2fc0f
Fix: added missing comment closing tag
derpaschi Nov 11, 2025
77b3528
Merge branch 'trunk' into feature/deleting-users-56914
ocean90 May 12, 2026
4f586b5
Update deprecation version for `delete_users_add_js()` function to 7.…
derpaschi May 12, 2026
47856ac
Remove invalid legends and include user logins in legends
ocean90 May 12, 2026
57f2ac2
Update labels for clarity and accessibility
ocean90 May 12, 2026
37ad656
Fix HTML structure in confirm_delete_users() by removing an extra clo…
ocean90 May 12, 2026
a7d3ac9
Fix PHPCS warnings
ocean90 May 12, 2026
93b4b7b
Prevent user deletion without reassignment
ocean90 May 12, 2026
73f2efa
Enhance user deletion process by validating reassignment selections a…
derpaschi Jun 4, 2026
9564905
Merge branch 'trunk' into pr/10502
joedolson Jul 3, 2026
bfe9536
Merge branch 'feature/deleting-users-56914' of https://github.com/wea…
joedolson Jul 3, 2026
4998465
Merge remote-tracking branch 'upstream/trunk' into copy-10502
joedolson Jul 9, 2026
f1a0104
Match legend styles when deleting users to paragraph
joedolson Jul 9, 2026
8532883
Mark username in bold, restore user ID
joedolson Jul 9, 2026
911771c
Remove list-style none
joedolson Jul 9, 2026
5d042ec
Add styling for errors, legends, and fix tokens for printf.
joedolson Jul 9, 2026
3eaaebb
Update src/wp-admin/includes/deprecated.php
joedolson Jul 10, 2026
3f8c951
Update src/js/_enqueues/admin/common.js
joedolson Jul 10, 2026
3d1af68
Update src/wp-admin/includes/deprecated.php
joedolson Jul 10, 2026
f1ccebe
Missing end paren
joedolson Jul 10, 2026
e2b17a5
Merge branch 'copy-10502' of https://github.com/joedolson/wordpress-d…
joedolson Jul 10, 2026
06385ca
Apply styling & fieldset in multisite, as well.
joedolson Jul 10, 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
239 changes: 239 additions & 0 deletions src/js/_enqueues/admin/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -2356,3 +2356,242 @@ $( function( $ ) {
// Expose public methods.
return pub;
})();

/**
* Validate the delete-and-reassign users form and surface an accessible
* error summary instead of disabling the submit button.
*
* Disabled buttons can't be discovered by assistive technology, so rather
* than blocking submission we let the form submit, intercept it when content
* decisions are still missing, and present a focusable error summary that
* lists how many decisions remain and links straight to each one.
*
* Shared by both the single-site (wp-admin/users.php) and multisite/network
* (confirm_delete_users() in wp-admin/includes/ms.php) deletion forms. The two
* differ in markup: single site has one content decision per user, multisite
* has one decision per site a user belongs to (several radio groups per
* fieldset), and their reassign dropdowns use different "no selection" values.
* The logic below works per radio group so it covers both.
*
* @since 7.1.0
*/
(function(){
const { _n, sprintf } = wp.i18n;
const usersForm = document.querySelector( '.delete-and-reassign-users-form' );

// Check if the form exists and contains any radio buttons.
if ( ! usersForm || ! usersForm.querySelector( 'input[type="radio"]' ) ) {
return;
}

const summaryId = 'delete-users-error-summary';

/**
* Whether a reassign dropdown has no user selected.
*
* The "Select a user" placeholder value differs between the forms: the
* single-site dropdown uses an empty string, the multisite one uses the
* wp_dropdown_users() default of '-1'.
*
* @param {HTMLSelectElement} select The reassign dropdown.
* @return {boolean} True when no real user is selected.
*/
function hasNoSelectedUser( select ) {
return '' === select.value || '-1' === select.value;
}

/**
* Builds a human-readable label for a radio group's decision.
*
* Combines the fieldset legend (the user) with the site context that
* precedes the group on multisite, so each summary entry is identifiable.
*
* @param {HTMLElement} group The radio group (<ul>) element.
* @return {string} The composed label.
*/
function getDecisionLabel( group ) {
const fieldset = group.closest( 'fieldset' );
const legend = fieldset ? fieldset.querySelector( 'legend' ) : null;
const parts = [];

if ( legend ) {
parts.push( legend.textContent.trim() );
}

// On multisite each radio group is preceded by a "Site: …" paragraph.
const previous = group.previousElementSibling;
if ( previous && previous !== legend && previous.textContent.trim() ) {
parts.push( previous.textContent.trim() );
}

return parts.join( ' – ' );
}

// Keep the radio selection in sync with the reassign dropdown.
usersForm.querySelectorAll( 'select' ).forEach( function( selectElement ) {
selectElement.addEventListener( 'change', function( e ) {
const item = e.target.closest( 'li' );
const radio = item ? item.querySelector( 'input[type="radio"]' ) : null;
if ( radio ) {
radio.checked = ! hasNoSelectedUser( e.target );
}
});
});

/**
* Returns the radio groups whose content decision is still incomplete.
*
* A decision unit is a single radio group (<ul>), which maps to one user on
* single site and one site-per-user on multisite.
*
* @return {Array} Objects describing each incomplete decision.
*/
function getIncompleteDecisions() {
const incomplete = [];

usersForm.querySelectorAll( 'fieldset ul' ).forEach( function( group ) {
const radios = group.querySelectorAll( 'input[type="radio"]' );
if ( ! radios.length ) {
return;
}

const checked = group.querySelector( 'input[type="radio"]:checked' );

// No option chosen yet.
if ( ! checked ) {
incomplete.push( { target: radios[ 0 ], label: getDecisionLabel( group ) } );
return;
}

// "Attribute to another user" chosen, but no user selected.
if ( 'reassign' === checked.value ) {
const select = group.querySelector( 'select' );
if ( select && hasNoSelectedUser( select ) ) {
incomplete.push( { target: select, label: getDecisionLabel( group ) } );
}
}
});

return incomplete;
}

/**
* Builds or refreshes the error summary markup.
*
* @param {Array} incomplete Incomplete decisions from getIncompleteDecisions().
* @return {string} The summary title, for announcing to assistive technology.
*/
function renderErrorSummary( incomplete ) {
let summary = document.getElementById( summaryId );

if ( ! summary ) {
summary = document.createElement( 'div' );
summary.id = summaryId;
summary.className = 'notice notice-error';
summary.setAttribute( 'tabindex', '-1' );

// The wrapper contains the form on single site and wraps it on
// multisite; insert the summary right after the page heading.
const wrap = usersForm.querySelector( '.wrap' ) || usersForm.closest( '.wrap' ) || usersForm;
const heading = wrap.querySelector( 'h1' );
wrap.insertBefore( summary, heading ? heading.nextSibling : wrap.firstChild );
}

const count = incomplete.length;
const title = sprintf(
/* translators: %s: Number of content decisions still required. */
_n(
'%s content decision is still required before you can delete.',
'%s content decisions are still required before you can delete.',
count
),
count
);

// Clear any previous markup and invalid states before rebuilding,
// so decisions resolved since the last render are no longer flagged.
summary.textContent = '';
usersForm.querySelectorAll( '[aria-invalid]' ).forEach( function( el ) {
el.removeAttribute( 'aria-invalid' );
});

const titleEl = document.createElement( 'p' );
const strong = document.createElement( 'strong' );
strong.textContent = title;
titleEl.appendChild( strong );
summary.appendChild( titleEl );

const list = document.createElement( 'ul' );
incomplete.forEach( function( item ) {
const li = document.createElement( 'li' );
const link = document.createElement( 'a' );
link.href = '#' + item.target.id;
link.textContent = item.label;
link.addEventListener( 'click', function( e ) {
e.preventDefault();
item.target.focus();
});
li.appendChild( link );
list.appendChild( li );

item.target.setAttribute( 'aria-invalid', 'true' );
});
summary.appendChild( list );

return title;
}

/**
* Removes the error summary and clears invalid states.
*/
function clearErrorState() {
const summary = document.getElementById( summaryId );
if ( summary ) {
summary.remove();
}
usersForm.querySelectorAll( '[aria-invalid]' ).forEach( function( el ) {
el.removeAttribute( 'aria-invalid' );
});
}

/**
* Refreshes the error summary to match the current form state.
*
* @param {boolean} moveFocus Whether to move focus to the summary and
* announce it (used on a failed submit).
* @return {number} The number of incomplete decisions.
*/
function updateSummary( moveFocus ) {
const incomplete = getIncompleteDecisions();

if ( ! incomplete.length ) {
clearErrorState();
return 0;
}

const title = renderErrorSummary( incomplete );

if ( moveFocus ) {
document.getElementById( summaryId ).focus();
if ( window.wp && window.wp.a11y ) {
window.wp.a11y.speak( title, 'assertive' );
}
}

return incomplete.length;
}

usersForm.addEventListener( 'submit', function( e ) {
if ( updateSummary( true ) > 0 ) {
e.preventDefault();
}
});

// Keep an existing summary current as decisions are resolved, without
// stealing focus on every interaction.
usersForm.addEventListener( 'change', function() {
if ( document.getElementById( summaryId ) ) {
updateSummary( false );
}
});
})();
17 changes: 17 additions & 0 deletions src/wp-admin/css/common.css
Original file line number Diff line number Diff line change
Expand Up @@ -2605,6 +2605,23 @@ body.iframe {
display: block;
}

.delete-and-reassign-users-form legend {
margin: 1em 0;
line-height: 1.5;
float: inline-start;
}

.delete-and-reassign-users-form fieldset > fieldset,
.delete-and-reassign-users-form fieldset > ul {
clear: both;
}

.delete-and-reassign-users-form fieldset:has(select[aria-invalid="true"]):not(fieldset:has(fieldset)) {
border-left: 4px solid #cc1818;
background: #fcf0f0;
padding-left: 12px;
}

.importers {
font-size: 16px;
width: auto;
Expand Down
11 changes: 11 additions & 0 deletions src/wp-admin/includes/deprecated.php
Original file line number Diff line number Diff line change
Expand Up @@ -1589,3 +1589,14 @@ function image_attachment_fields_to_save( $post, $attachment ) {

return $post;
}

/**
* Was used to add JavaScript to the delete users form.
*
* @since 3.5.0
* @deprecated 7.1.0
* @access private
*/
function delete_users_add_js() {
_deprecated_function( __FUNCTION__, '7.1.0' );
}
Loading
Loading