Commit 4dcf1d1cd31 for woocommerce

commit 4dcf1d1cd31c29259d9c7ddafb9396ddb8c7f800
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Thu Jul 23 08:23:47 2026 +0300

    Fix radio setting group labels (#66705)

    * fix: remove invalid radio setting header labels

    Classic WooCommerce settings radio rows rendered the visible table header as a label whose for attribute pointed at the setting id, but no radio input uses that id. This left the markup with an invalid label association.

    Render the radio row header as plain text, add a screen-reader legend to the existing fieldset, and keep the individual native radio option labels, checked state, disabled state, descriptions, and tooltip output intact.

    Refs #55669

    * test: strengthen radio setting markup coverage

    The radio settings regression test previously relied on serialized substrings and asserted option state unrelated to the invalid header label fix. That made the test sensitive to formatting while leaving the preserved title and tooltip relationship implicit.\n\nParse the rendered fragment and assert the affected header, tooltip, legend, and radio structures directly. Inline exception-safe output capture and remove the one-use helper to keep the test focused.\n\nRefs #55669

    * fix: preserve radio setting tooltip positioning

    Radio setting headers no longer use a label because the group inputs do not expose a matching ID.

    Removing that label also removed the CSS hook that positioned help tips, while the checkbox-style fallback clips the tip on the responsive Logging screen.

    Add a semantic-neutral header hook with desktop and mobile positioning, and pin that contract in the DOM test while guarding libxml parsing.

    Refs #55669

    * fix: preserve radio setting tooltip alignment

    Radio setting titles no longer use an invalid label, but moving the help tip's positioning context to the table header caused vertical drift on rows with multiple options or descriptions.

    Wrap the title and help tip in a neutral element that reuses the existing title-label positioning rules. This keeps the accessible fieldset legend while restoring desktop and responsive alignment.

    Refs #55669

    * Add justification to radio tooltip PHPCS ignore

    * fix: Label radio groups from their visible titles

    Radio setting fields used a screen-reader-only legend that duplicated the visible row title. VoiceOver therefore announced the title twice in the affected navigation path.

    Name each fieldset from a title-only visible span with aria-labelledby. This preserves the existing row header, tooltip, and individual option labels while keeping the help text out of the group name.

    Update structural coverage to lock the visible-title relationship and absence of the duplicate legend.

diff --git a/plugins/woocommerce/changelog/fix-55669-settings-radio-labels b/plugins/woocommerce/changelog/fix-55669-settings-radio-labels
new file mode 100644
index 00000000000..9d51fa3e79f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-55669-settings-radio-labels
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix invalid label markup for classic settings radio fields.
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index 8fc13498a42..345bd24d8f3 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -5351,7 +5351,8 @@ img.help_tip {
 			padding-right: 24px;
 		}

-		th label {
+		th label,
+		th .wc-settings-radio-title {
 			position: relative;
 			display: block;

@@ -5564,7 +5565,8 @@ img.help_tip {
 				}
 			}

-			th label {
+			th label,
+			th .wc-settings-radio-title {
 				img.help_tip,
 				.woocommerce-help-tip {
 					margin: -7px -24px 0 0;
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-settings.php b/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
index 0cd4f06c8c2..203f9fae2a6 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
@@ -513,14 +513,21 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) :
 						$option_value     = $value['value'];
 						$disabled_values  = $value['disabled'] ?? array();
 						$show_desc_at_end = $value['desc_at_end'] ?? false;
+						$radio_title_id   = $value['id'] . '-title';

 						?>
 						<tr class="<?php echo esc_attr( $value['row_class'] ); ?>">
 							<th scope="row" class="titledesc">
-								<label for="<?php echo esc_attr( $value['id'] ); ?>"><?php echo esc_html( $value['title'] ); ?> <?php echo $tooltip_html; // WPCS: XSS ok. ?></label>
+								<span class="wc-settings-radio-title">
+									<span id="<?php echo esc_attr( $radio_title_id ); ?>"><?php echo esc_html( $value['title'] ); ?></span>
+									<?php
+									// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built by self::get_field_description(), which passes the tip through wc_help_tip(); that helper sanitizes the tip text and escapes the aria-label.
+									echo $tooltip_html;
+									?>
+								</span>
 							</th>
 							<td class="forminp forminp-<?php echo esc_attr( sanitize_title( $value['type'] ) ); ?>">
-								<fieldset>
+								<fieldset aria-labelledby="<?php echo esc_attr( $radio_title_id ); ?>">
 									<?php
 									if ( ! $show_desc_at_end ) {
 										echo wp_kses_post( $description );
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-settings-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-settings-test.php
index 666ccdf7e47..1c3fd8f3a5b 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-settings-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-settings-test.php
@@ -290,6 +290,56 @@ class WC_Admin_Settings_Test extends WC_Unit_Test_Case {
 		$this->assertFalse( $redirect_attempted );
 	}

+	/**
+	 * @testdox Should label radio settings from their visible title.
+	 */
+	public function test_output_fields_labels_radio_setting_from_visible_title(): void {
+		$options = array(
+			array(
+				'id'       => 'test_radio_setting',
+				'title'    => 'Radio title',
+				'type'     => 'radio',
+				'value'    => 'abc',
+				'options'  => array(
+					'abc' => 'First option',
+					'xyz' => 'Second option',
+				),
+				'desc_tip' => 'Radio help',
+			),
+		);
+
+		ob_start();
+		try {
+			WC_Admin_Settings::output_fields( $options );
+			$output = (string) ob_get_contents();
+		} finally {
+			ob_end_clean();
+		}
+
+		$document       = new DOMDocument();
+		$previous_state = libxml_use_internal_errors( true );
+		$loaded         = $document->loadHTML( '<table>' . $output . '</table>' );
+		libxml_clear_errors();
+		libxml_use_internal_errors( $previous_state );
+
+		$this->assertTrue( $loaded, 'The radio setting output should be valid enough for DOM parsing.' );
+
+		$xpath = new DOMXPath( $document );
+
+		$header      = '//th[contains(concat(" ", normalize-space(@class), " "), " titledesc ")]';
+		$radio_title = $header . '/span[contains(concat(" ", normalize-space(@class), " "), " wc-settings-radio-title ")]';
+		$title_text  = $radio_title . '/span[@id="test_radio_setting-title"]';
+		$radio       = '//td[contains(concat(" ", normalize-space(@class), " "), " forminp-radio ")]';
+
+		$this->assertSame( 0, $xpath->query( $header . '/label[@for="test_radio_setting"]' )->length );
+		$this->assertSame( 0, $xpath->query( $radio_title . '[@id]' )->length );
+		$this->assertSame( 1, $xpath->query( $title_text . '[normalize-space(.)="Radio title"]' )->length );
+		$this->assertSame( 1, $xpath->query( $radio_title . '/span[contains(concat(" ", normalize-space(@class), " "), " woocommerce-help-tip ")][@aria-label="Radio help"]' )->length );
+		$this->assertSame( 1, $xpath->query( $radio . '/fieldset[@aria-labelledby="test_radio_setting-title"]' )->length );
+		$this->assertSame( 0, $xpath->query( $radio . '/fieldset/legend' )->length );
+		$this->assertSame( 2, $xpath->query( $radio . '//input[@type="radio"]' )->length );
+	}
+
 	/**
 	 * Prepare globals used by WC_Admin_Settings::save().
 	 *