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().
*