Commit 42845cdaf43 for woocommerce

commit 42845cdaf4352f6f870959c117ab7b960b6f6751
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Fri Jul 24 13:36:05 2026 +0300

    Fix duplicate title ID on ID-less settings radio fields (#66944)

    * fix(admin): Prevent ID-less radio settings from sharing a title ID

    PR #66705 gave classic settings radio rows a `<span id="{id}-title">` title
    and named the `<fieldset>` from it with `aria-labelledby`, replacing an
    orphaned `<label for="{id}">` that never resolved (radio inputs carry no id).

    `WC_Admin_Settings::output_fields()` normalizes a missing `id` to an empty
    string, so an id-less radio field produced `id="-title"`. Two such fields on
    one page emitted duplicate `-title` ids, and both fieldsets' `aria-labelledby`
    resolved to the first group's title — actively mislabeling the second group
    for assistive technology. The previous `for=""` was merely inert, so this was
    a small regression from "unlabeled" to "mislabeled".

    Only build the title id, and only emit the `id`/`aria-labelledby` attributes,
    when the field has a non-empty id. Id-less radio groups fall back to no
    programmatic name (as before) instead of colliding on a shared id. Add
    coverage asserting two id-less radio fields emit neither a `-title` id nor an
    `aria-labelledby`, while still rendering both titles and fieldsets.

    Refs #66913

    * fix(admin): Normalize non-string radio setting IDs before building title ID

    The ID-less radio title-ID guard used `'' !== $value['id']`, a strict
    comparison that only catches the empty-string default. output_fields()
    normalizes a *missing* id to '' (and null collapses to '' via isset), but an
    extension can still pass a non-string id explicitly, and those slip past the
    guard:

    - `id => false` satisfies `'' !== false`, so `false . '-title'` yields the bare
      "-title" — reintroducing the very shared-ID collision this fix removes.
    - `id => array(...)` or an object trips an "Array/Object to string conversion"
      on concatenation.

    Normalize the id to a scalar string first: non-scalars and false collapse to ''
    and fall back to no programmatic title (as an ID-less field already does),
    while legitimate scalar IDs (including '0') are preserved. Add coverage for
    false, array, and object IDs asserting none emit a "-title" ID or an
    aria-labelledby while all three groups still render their titles and fieldsets.

    Refs #66913

    * test(admin): Cover string zero radio setting IDs

    Radio ID normalization preserves the scalar string zero, but the regression coverage only exercised empty or invalid identifiers. A future truthiness check could silently remove the title relationship.

    Add a string-zero fixture. Assert the exact title ID plus matching aria-labelledby relationship. Keep the existing invalid-ID cases intact.

    Refs #66913

    * chore(admin): Simplify radio ID normalization comments

    The normalization comments repeated edge-case rationale already expressed by the implementation plus its coverage. This made the focused code harder to scan.

    Reduce each comment to one sentence describing intent. Preserve behavior.

    Refs #66913

    * test(admin): Reject identifiers on invalid radio IDs

    The normalization test verified the expected 0-title relationship, but it did not constrain extra identifiers. Invalid IDs could therefore produce unrelated title IDs without failing coverage.

    Assert a single title ID plus a single aria-labelledby attribute across all rows. This pins 0-title as the sole relationship.

    Refs #66913

diff --git a/plugins/woocommerce/changelog/66913-fix-radio-setting-empty-id b/plugins/woocommerce/changelog/66913-fix-radio-setting-empty-id
new file mode 100644
index 00000000000..ccad2901c3f
--- /dev/null
+++ b/plugins/woocommerce/changelog/66913-fix-radio-setting-empty-id
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent ID-less classic settings radio fields from sharing a single "-title" ID that could cross-label radio groups for assistive technology.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-settings.php b/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
index 203f9fae2a6..87880c9e9fa 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
@@ -513,13 +513,15 @@ 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';
+						// Only build a title ID when the field has a usable ID.
+						$normalized_id  = is_scalar( $value['id'] ) ? (string) $value['id'] : '';
+						$radio_title_id = '' !== $normalized_id ? $normalized_id . '-title' : '';

 						?>
 						<tr class="<?php echo esc_attr( $value['row_class'] ); ?>">
 							<th scope="row" class="titledesc">
 								<span class="wc-settings-radio-title">
-									<span id="<?php echo esc_attr( $radio_title_id ); ?>"><?php echo esc_html( $value['title'] ); ?></span>
+									<span<?php echo '' !== $radio_title_id ? ' id="' . 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;
@@ -527,7 +529,7 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) :
 								</span>
 							</th>
 							<td class="forminp forminp-<?php echo esc_attr( sanitize_title( $value['type'] ) ); ?>">
-								<fieldset aria-labelledby="<?php echo esc_attr( $radio_title_id ); ?>">
+								<fieldset<?php echo '' !== $radio_title_id ? ' aria-labelledby="' . 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 1c3fd8f3a5b..bb73ad07185 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
@@ -340,6 +340,132 @@ class WC_Admin_Settings_Test extends WC_Unit_Test_Case {
 		$this->assertSame( 2, $xpath->query( $radio . '//input[@type="radio"]' )->length );
 	}

+	/**
+	 * @testdox Should not emit a shared "-title" ID for radio settings that have no ID.
+	 */
+	public function test_output_fields_does_not_cross_label_id_less_radio_settings(): void {
+		$options = array(
+			array(
+				'title'   => 'First radio',
+				'type'    => 'radio',
+				'value'   => 'a',
+				'options' => array( 'a' => 'First option' ),
+			),
+			array(
+				'title'   => 'Second radio',
+				'type'    => 'radio',
+				'value'   => 'b',
+				'options' => array( 'b' => 'Second option' ),
+			),
+		);
+
+		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 );
+
+		$radio_title = '//th[contains(concat(" ", normalize-space(@class), " "), " titledesc ")]/span[contains(concat(" ", normalize-space(@class), " "), " wc-settings-radio-title ")]';
+		$fieldset    = '//td[contains(concat(" ", normalize-space(@class), " "), " forminp-radio ")]/fieldset';
+
+		// Both rows render, but neither emits the shared "-title" ID or an aria-labelledby pointing at it.
+		$this->assertSame( 2, $xpath->query( $radio_title )->length );
+		$this->assertSame( 2, $xpath->query( $fieldset )->length );
+		$this->assertSame( 0, $xpath->query( $radio_title . '/span[@id="-title"]' )->length );
+		$this->assertSame( 0, $xpath->query( $radio_title . '/span[@id]' )->length );
+		$this->assertSame( 0, $xpath->query( $fieldset . '[@aria-labelledby]' )->length );
+		// Visible titles are still rendered for both groups.
+		$this->assertSame( 1, $xpath->query( $radio_title . '/span[normalize-space(.)="First radio"]' )->length );
+		$this->assertSame( 1, $xpath->query( $radio_title . '/span[normalize-space(.)="Second radio"]' )->length );
+	}
+
+	/**
+	 * @testdox Should treat a non-string radio setting ID as no ID rather than a shared or malformed "-title".
+	 */
+	public function test_output_fields_normalizes_non_string_radio_ids(): void {
+		// Explicit field names isolate title-ID normalization from input naming.
+		$options = array(
+			array(
+				'title'   => 'String zero ID radio',
+				'type'    => 'radio',
+				'id'      => '0',
+				'value'   => 'd',
+				'options' => array( 'd' => 'Fourth option' ),
+			),
+			array(
+				'title'      => 'Boolean ID radio',
+				'type'       => 'radio',
+				'id'         => false,
+				'field_name' => 'boolean_id_radio',
+				'value'      => 'a',
+				'options'    => array( 'a' => 'First option' ),
+			),
+			array(
+				'title'      => 'Array ID radio',
+				'type'       => 'radio',
+				'id'         => array( 'unexpected' ),
+				'field_name' => 'array_id_radio',
+				'value'      => 'b',
+				'options'    => array( 'b' => 'Second option' ),
+			),
+			array(
+				'title'      => 'Object ID radio',
+				'type'       => 'radio',
+				'id'         => new stdClass(),
+				'field_name' => 'object_id_radio',
+				'value'      => 'c',
+				'options'    => array( 'c' => 'Third option' ),
+			),
+		);
+
+		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 );
+
+		$radio_title = '//th[contains(concat(" ", normalize-space(@class), " "), " titledesc ")]/span[contains(concat(" ", normalize-space(@class), " "), " wc-settings-radio-title ")]';
+		$fieldset    = '//td[contains(concat(" ", normalize-space(@class), " "), " forminp-radio ")]/fieldset';
+
+		// All four rows render, with the string zero ID preserved and the non-string IDs omitted.
+		$this->assertSame( 4, $xpath->query( $radio_title )->length );
+		$this->assertSame( 4, $xpath->query( $fieldset )->length );
+		$this->assertSame( 0, $xpath->query( $radio_title . '/span[@id="-title"]' )->length );
+		$this->assertSame( 1, $xpath->query( $radio_title . '/span[@id]' )->length );
+		$this->assertSame( 1, $xpath->query( $fieldset . '[@aria-labelledby]' )->length );
+		$this->assertSame( 1, $xpath->query( $radio_title . '/span[@id="0-title"]' )->length );
+		$this->assertSame( 1, $xpath->query( $fieldset . '[@aria-labelledby="0-title"]' )->length );
+		// Visible titles are still rendered for every group.
+		$this->assertSame( 1, $xpath->query( $radio_title . '/span[normalize-space(.)="String zero ID radio"]' )->length );
+		$this->assertSame( 1, $xpath->query( $radio_title . '/span[normalize-space(.)="Boolean ID radio"]' )->length );
+		$this->assertSame( 1, $xpath->query( $radio_title . '/span[normalize-space(.)="Array ID radio"]' )->length );
+		$this->assertSame( 1, $xpath->query( $radio_title . '/span[normalize-space(.)="Object ID radio"]' )->length );
+	}
+
 	/**
 	 * Prepare globals used by WC_Admin_Settings::save().
 	 *