Skip to content

Commit 1ba91d1

Browse files
authored
Merge branch 'WordPress:trunk' into trunk
2 parents af1a85d + 234ae9b commit 1ba91d1

13 files changed

Lines changed: 706 additions & 532 deletions

File tree

src/js/_enqueues/admin/common.js

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2356,3 +2356,242 @@ $( function( $ ) {
23562356
// Expose public methods.
23572357
return pub;
23582358
})();
2359+
2360+
/**
2361+
* Validate the delete-and-reassign users form and surface an accessible
2362+
* error summary instead of disabling the submit button.
2363+
*
2364+
* Disabled buttons can't be discovered by assistive technology, so rather
2365+
* than blocking submission we let the form submit, intercept it when content
2366+
* decisions are still missing, and present a focusable error summary that
2367+
* lists how many decisions remain and links straight to each one.
2368+
*
2369+
* Shared by both the single-site (wp-admin/users.php) and multisite/network
2370+
* (confirm_delete_users() in wp-admin/includes/ms.php) deletion forms. The two
2371+
* differ in markup: single site has one content decision per user, multisite
2372+
* has one decision per site a user belongs to (several radio groups per
2373+
* fieldset), and their reassign dropdowns use different "no selection" values.
2374+
* The logic below works per radio group so it covers both.
2375+
*
2376+
* @since 7.1.0
2377+
*/
2378+
(function(){
2379+
const { _n, sprintf } = wp.i18n;
2380+
const usersForm = document.querySelector( '.delete-and-reassign-users-form' );
2381+
2382+
// Check if the form exists and contains any radio buttons.
2383+
if ( ! usersForm || ! usersForm.querySelector( 'input[type="radio"]' ) ) {
2384+
return;
2385+
}
2386+
2387+
const summaryId = 'delete-users-error-summary';
2388+
2389+
/**
2390+
* Whether a reassign dropdown has no user selected.
2391+
*
2392+
* The "Select a user" placeholder value differs between the forms: the
2393+
* single-site dropdown uses an empty string, the multisite one uses the
2394+
* wp_dropdown_users() default of '-1'.
2395+
*
2396+
* @param {HTMLSelectElement} select The reassign dropdown.
2397+
* @return {boolean} True when no real user is selected.
2398+
*/
2399+
function hasNoSelectedUser( select ) {
2400+
return '' === select.value || '-1' === select.value;
2401+
}
2402+
2403+
/**
2404+
* Builds a human-readable label for a radio group's decision.
2405+
*
2406+
* Combines the fieldset legend (the user) with the site context that
2407+
* precedes the group on multisite, so each summary entry is identifiable.
2408+
*
2409+
* @param {HTMLElement} group The radio group (<ul>) element.
2410+
* @return {string} The composed label.
2411+
*/
2412+
function getDecisionLabel( group ) {
2413+
const fieldset = group.closest( 'fieldset' );
2414+
const legend = fieldset ? fieldset.querySelector( 'legend' ) : null;
2415+
const parts = [];
2416+
2417+
if ( legend ) {
2418+
parts.push( legend.textContent.trim() );
2419+
}
2420+
2421+
// On multisite each radio group is preceded by a "Site: …" paragraph.
2422+
const previous = group.previousElementSibling;
2423+
if ( previous && previous !== legend && previous.textContent.trim() ) {
2424+
parts.push( previous.textContent.trim() );
2425+
}
2426+
2427+
return parts.join( ' – ' );
2428+
}
2429+
2430+
// Keep the radio selection in sync with the reassign dropdown.
2431+
usersForm.querySelectorAll( 'select' ).forEach( function( selectElement ) {
2432+
selectElement.addEventListener( 'change', function( e ) {
2433+
const item = e.target.closest( 'li' );
2434+
const radio = item ? item.querySelector( 'input[type="radio"]' ) : null;
2435+
if ( radio ) {
2436+
radio.checked = ! hasNoSelectedUser( e.target );
2437+
}
2438+
});
2439+
});
2440+
2441+
/**
2442+
* Returns the radio groups whose content decision is still incomplete.
2443+
*
2444+
* A decision unit is a single radio group (<ul>), which maps to one user on
2445+
* single site and one site-per-user on multisite.
2446+
*
2447+
* @return {Array} Objects describing each incomplete decision.
2448+
*/
2449+
function getIncompleteDecisions() {
2450+
const incomplete = [];
2451+
2452+
usersForm.querySelectorAll( 'fieldset ul' ).forEach( function( group ) {
2453+
const radios = group.querySelectorAll( 'input[type="radio"]' );
2454+
if ( ! radios.length ) {
2455+
return;
2456+
}
2457+
2458+
const checked = group.querySelector( 'input[type="radio"]:checked' );
2459+
2460+
// No option chosen yet.
2461+
if ( ! checked ) {
2462+
incomplete.push( { target: radios[ 0 ], label: getDecisionLabel( group ) } );
2463+
return;
2464+
}
2465+
2466+
// "Attribute to another user" chosen, but no user selected.
2467+
if ( 'reassign' === checked.value ) {
2468+
const select = group.querySelector( 'select' );
2469+
if ( select && hasNoSelectedUser( select ) ) {
2470+
incomplete.push( { target: select, label: getDecisionLabel( group ) } );
2471+
}
2472+
}
2473+
});
2474+
2475+
return incomplete;
2476+
}
2477+
2478+
/**
2479+
* Builds or refreshes the error summary markup.
2480+
*
2481+
* @param {Array} incomplete Incomplete decisions from getIncompleteDecisions().
2482+
* @return {string} The summary title, for announcing to assistive technology.
2483+
*/
2484+
function renderErrorSummary( incomplete ) {
2485+
let summary = document.getElementById( summaryId );
2486+
2487+
if ( ! summary ) {
2488+
summary = document.createElement( 'div' );
2489+
summary.id = summaryId;
2490+
summary.className = 'notice notice-error';
2491+
summary.setAttribute( 'tabindex', '-1' );
2492+
2493+
// The wrapper contains the form on single site and wraps it on
2494+
// multisite; insert the summary right after the page heading.
2495+
const wrap = usersForm.querySelector( '.wrap' ) || usersForm.closest( '.wrap' ) || usersForm;
2496+
const heading = wrap.querySelector( 'h1' );
2497+
wrap.insertBefore( summary, heading ? heading.nextSibling : wrap.firstChild );
2498+
}
2499+
2500+
const count = incomplete.length;
2501+
const title = sprintf(
2502+
/* translators: %s: Number of content decisions still required. */
2503+
_n(
2504+
'%s content decision is still required before you can delete.',
2505+
'%s content decisions are still required before you can delete.',
2506+
count
2507+
),
2508+
count
2509+
);
2510+
2511+
// Clear any previous markup and invalid states before rebuilding,
2512+
// so decisions resolved since the last render are no longer flagged.
2513+
summary.textContent = '';
2514+
usersForm.querySelectorAll( '[aria-invalid]' ).forEach( function( el ) {
2515+
el.removeAttribute( 'aria-invalid' );
2516+
});
2517+
2518+
const titleEl = document.createElement( 'p' );
2519+
const strong = document.createElement( 'strong' );
2520+
strong.textContent = title;
2521+
titleEl.appendChild( strong );
2522+
summary.appendChild( titleEl );
2523+
2524+
const list = document.createElement( 'ul' );
2525+
incomplete.forEach( function( item ) {
2526+
const li = document.createElement( 'li' );
2527+
const link = document.createElement( 'a' );
2528+
link.href = '#' + item.target.id;
2529+
link.textContent = item.label;
2530+
link.addEventListener( 'click', function( e ) {
2531+
e.preventDefault();
2532+
item.target.focus();
2533+
});
2534+
li.appendChild( link );
2535+
list.appendChild( li );
2536+
2537+
item.target.setAttribute( 'aria-invalid', 'true' );
2538+
});
2539+
summary.appendChild( list );
2540+
2541+
return title;
2542+
}
2543+
2544+
/**
2545+
* Removes the error summary and clears invalid states.
2546+
*/
2547+
function clearErrorState() {
2548+
const summary = document.getElementById( summaryId );
2549+
if ( summary ) {
2550+
summary.remove();
2551+
}
2552+
usersForm.querySelectorAll( '[aria-invalid]' ).forEach( function( el ) {
2553+
el.removeAttribute( 'aria-invalid' );
2554+
});
2555+
}
2556+
2557+
/**
2558+
* Refreshes the error summary to match the current form state.
2559+
*
2560+
* @param {boolean} moveFocus Whether to move focus to the summary and
2561+
* announce it (used on a failed submit).
2562+
* @return {number} The number of incomplete decisions.
2563+
*/
2564+
function updateSummary( moveFocus ) {
2565+
const incomplete = getIncompleteDecisions();
2566+
2567+
if ( ! incomplete.length ) {
2568+
clearErrorState();
2569+
return 0;
2570+
}
2571+
2572+
const title = renderErrorSummary( incomplete );
2573+
2574+
if ( moveFocus ) {
2575+
document.getElementById( summaryId ).focus();
2576+
if ( window.wp && window.wp.a11y ) {
2577+
window.wp.a11y.speak( title, 'assertive' );
2578+
}
2579+
}
2580+
2581+
return incomplete.length;
2582+
}
2583+
2584+
usersForm.addEventListener( 'submit', function( e ) {
2585+
if ( updateSummary( true ) > 0 ) {
2586+
e.preventDefault();
2587+
}
2588+
});
2589+
2590+
// Keep an existing summary current as decisions are resolved, without
2591+
// stealing focus on every interaction.
2592+
usersForm.addEventListener( 'change', function() {
2593+
if ( document.getElementById( summaryId ) ) {
2594+
updateSummary( false );
2595+
}
2596+
});
2597+
})();

src/wp-admin/css/common.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2605,6 +2605,23 @@ body.iframe {
26052605
display: block;
26062606
}
26072607

2608+
.delete-and-reassign-users-form legend {
2609+
margin: 1em 0;
2610+
line-height: 1.5;
2611+
float: inline-start;
2612+
}
2613+
2614+
.delete-and-reassign-users-form fieldset > fieldset,
2615+
.delete-and-reassign-users-form fieldset > ul {
2616+
clear: both;
2617+
}
2618+
2619+
.delete-and-reassign-users-form fieldset:has(select[aria-invalid="true"]):not(fieldset:has(fieldset)) {
2620+
border-left: 4px solid #cc1818;
2621+
background: #fcf0f0;
2622+
padding-left: 12px;
2623+
}
2624+
26082625
.importers {
26092626
font-size: 16px;
26102627
width: auto;

src/wp-admin/includes/deprecated.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1589,3 +1589,14 @@ function image_attachment_fields_to_save( $post, $attachment ) {
15891589

15901590
return $post;
15911591
}
1592+
1593+
/**
1594+
* Was used to add JavaScript to the delete users form.
1595+
*
1596+
* @since 3.5.0
1597+
* @deprecated 7.1.0
1598+
* @access private
1599+
*/
1600+
function delete_users_add_js() {
1601+
_deprecated_function( __FUNCTION__, '7.1.0' );
1602+
}

0 commit comments

Comments
 (0)