@@ -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+ } ) ( ) ;
0 commit comments