Commit 21b027a326 for woocommerce

commit 21b027a326853a573674eb6e960301ad032b0da5
Author: Copilot <198982749+Copilot@users.noreply.github.com>
Date:   Tue Jan 13 13:47:40 2026 +0000

    Fix: Remove 'for' attribute from labels pointing to hidden fields (#61835)

    * Initial plan

    * Fix: Remove 'for' attribute from labels pointing to hidden fields

    When only one country is available in checkout settings, the country field becomes a hidden input. This fix ensures that labels do not have a 'for' attribute when pointing to hidden fields, which resolves HTML/ARIA validation errors.

    Co-authored-by: opr <5656702+opr@users.noreply.github.com>

    * Fix deprecated PHPUnit setMethods() in tests

    Updated test mocks to use onlyMethods() instead of deprecated setMethods() method for PHPUnit 9+ compatibility.

    Co-authored-by: opr <5656702+opr@users.noreply.github.com>

    * Fix code spacing for consistency

    Co-authored-by: opr <5656702+opr@users.noreply.github.com>

    * Use ::class syntax in test mocks for better IDE support

    Co-authored-by: opr <5656702+opr@users.noreply.github.com>

    * Refactor label generation to use $maybe_for_attr variable

    Replaced if/else block with a single line using a ternary operator to construct the 'for' attribute conditionally. This reduces code duplication while maintaining the same functionality.

    Co-authored-by: opr <5656702+opr@users.noreply.github.com>

    * use readonly input if only one country is available

    * Style country input like it was before (when it was a span)

    * Update unit tests to cover updated country input

    * Target readonly country via attribute instead of class

    * Add changelog

    * Revert to using a strong, but only visually hide the input

    * lint fix

    * add aria-hidden country input field when it has only a single option

    * Update single-option country fields to use selects styled as plain text

    ---------

    Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
    Co-authored-by: opr <5656702+opr@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/copilot-fix-hidden-field-labels b/plugins/woocommerce/changelog/copilot-fix-hidden-field-labels
new file mode 100644
index 0000000000..291ae04c5c
--- /dev/null
+++ b/plugins/woocommerce/changelog/copilot-fix-hidden-field-labels
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix an a11y issue when only one country is available, ensure the country is still rendered as an input field, but retain existing styling.
diff --git a/plugins/woocommerce/client/legacy/css/forms.scss b/plugins/woocommerce/client/legacy/css/forms.scss
index 2be90dbba5..d23300e074 100644
--- a/plugins/woocommerce/client/legacy/css/forms.scss
+++ b/plugins/woocommerce/client/legacy/css/forms.scss
@@ -101,6 +101,19 @@
 			box-shadow: none;
 		}

+		.country_to_state--single {
+			background: transparent;
+			border: 0;
+			padding: 0;
+			appearance: none;
+			background-image: none;
+			font: inherit;
+			font-weight: 700;
+			color: inherit;
+			width: auto;
+			pointer-events: none;
+		}
+
 		.required {
 			color: var(--wc-red);
 			font-weight: 700;
diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index f32edc09ac..70419b23c6 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -3082,16 +3082,19 @@ if ( ! function_exists( 'woocommerce_form_field' ) ) {
 		$label_id        = $args['id'];
 		$sort            = $args['priority'] ? $args['priority'] : '';
 		$field_container = '<p class="form-row %1$s" id="%2$s" data-priority="' . esc_attr( $sort ) . '">%3$s</p>';
+		$is_hidden_field = false;

 		switch ( $args['type'] ) {
 			case 'country':
 				$countries = 'shipping_country' === $key ? WC()->countries->get_shipping_countries() : WC()->countries->get_allowed_countries();

 				if ( 1 === count( $countries ) ) {
+					$country_code = current( array_keys( $countries ) );
+					$country_name = current( array_values( $countries ) );

-					$field .= '<strong>' . current( array_values( $countries ) ) . '</strong>';
-
-					$field .= '<input type="hidden" name="' . esc_attr( $key ) . '" id="' . esc_attr( $args['id'] ) . '" value="' . current( array_keys( $countries ) ) . '" ' . implode( ' ', $custom_attributes ) . ' class="country_to_state" readonly="readonly" />';
+					$field .= '<select name="' . esc_attr( $key ) . '" id="' . esc_attr( $args['id'] ) . '" ' . implode( ' ', $custom_attributes ) . ' class="country_to_state country_to_state--single ' . esc_attr( implode( ' ', $args['input_class'] ) ) . '">';
+					$field .= '<option value="' . esc_attr( $country_code ) . '" selected>' . esc_html( $country_name ) . '</option>';
+					$field .= '</select>';

 				} else {
 					$data_label = ! empty( $args['label'] ) ? 'data-label="' . esc_attr( $args['label'] ) . '"' : '';
@@ -3181,7 +3184,8 @@ if ( ! function_exists( 'woocommerce_form_field' ) ) {

 				break;
 			case 'hidden':
-				$field .= '<input type="' . esc_attr( $args['type'] ) . '" class="input-hidden ' . esc_attr( implode( ' ', $args['input_class'] ) ) . '" name="' . esc_attr( $key ) . '" id="' . esc_attr( $args['id'] ) . '" value="' . esc_attr( $value ) . '" ' . implode( ' ', $custom_attributes ) . ' />';
+				$field          .= '<input type="' . esc_attr( $args['type'] ) . '" class="input-hidden ' . esc_attr( implode( ' ', $args['input_class'] ) ) . '" name="' . esc_attr( $key ) . '" id="' . esc_attr( $args['id'] ) . '" value="' . esc_attr( $value ) . '" ' . implode( ' ', $custom_attributes ) . ' />';
+				$is_hidden_field = true;

 				break;
 			case 'select':
@@ -3222,7 +3226,8 @@ if ( ! function_exists( 'woocommerce_form_field' ) ) {
 			$field_html = '';

 			if ( $args['label'] && 'checkbox' !== $args['type'] ) {
-				$field_html .= '<label for="' . esc_attr( $label_id ) . '" class="' . esc_attr( implode( ' ', $args['label_class'] ) ) . '">' . wp_kses_post( $args['label'] ) . $required_indicator . '</label>';
+				$maybe_for_attr = $is_hidden_field ? '' : ' for="' . esc_attr( $label_id ) . '"';
+				$field_html    .= '<label' . $maybe_for_attr . ' class="' . esc_attr( implode( ' ', $args['label_class'] ) ) . '">' . wp_kses_post( $args['label'] ) . $required_indicator . '</label>';
 			}

 			$field_html .= '<span class="woocommerce-input-wrapper">' . $field;
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/templates/functions.php b/plugins/woocommerce/tests/legacy/unit-tests/templates/functions.php
index d09efeba3b..b41e3d3b9c 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/templates/functions.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/templates/functions.php
@@ -411,4 +411,112 @@ class WC_Tests_Template_Functions extends WC_Unit_Test_Case {
 		$this->assertStringContainsString( 'aria-label="Page 0"', $output );
 		$this->assertStringContainsString( 'aria-label="Page 0"', $output );
 	}
+
+	/**
+	 * Test that hidden field with label does not have "for" attribute.
+	 */
+	public function test_hidden_field_with_label() {
+		$actual_html = woocommerce_form_field(
+			'test_hidden',
+			array(
+				'type'   => 'hidden',
+				'id'     => 'test_hidden_field',
+				'label'  => 'Test Label',
+				'return' => true,
+			),
+			'test value'
+		);
+
+		// Should contain label without "for" attribute.
+		$this->assertStringContainsString( '<label class="">', $actual_html );
+		$this->assertStringNotContainsString( 'for=', $actual_html );
+		$this->assertStringContainsString( 'Test Label', $actual_html );
+	}
+
+	/**
+	 * Test that country field with one country uses a readonly text input.
+	 */
+	public function test_country_field_single_country() {
+		// Mock WC()->countries to return only one country.
+		$mock_countries = $this->getMockBuilder( WC_Countries::class )
+			->onlyMethods( array( 'get_allowed_countries' ) )
+			->getMock();
+
+		$mock_countries->method( 'get_allowed_countries' )
+			->willReturn( array( 'US' => 'United States' ) );
+
+		// Store original countries object.
+		$original_countries = WC()->countries;
+		WC()->countries     = $mock_countries;
+
+		$actual_html = woocommerce_form_field(
+			'billing_country',
+			array(
+				'type'   => 'country',
+				'id'     => 'billing_country',
+				'label'  => 'Country / Region',
+				'return' => true,
+			),
+			'US'
+		);
+
+		// Restore original countries object.
+		WC()->countries = $original_countries;
+
+		// Should contain label "for" attribute pointing to the select.
+		$this->assertStringContainsString( 'for="billing_country"', $actual_html );
+		$this->assertStringContainsString( 'Country / Region', $actual_html );
+		// Should contain single-option select styled as plain text.
+		$this->assertStringContainsString( '<select', $actual_html );
+		$this->assertStringContainsString( 'country_to_state--single', $actual_html );
+		$this->assertStringContainsString( 'value="US"', $actual_html );
+		$this->assertStringContainsString( '>United States</option>', $actual_html );
+		// Should NOT have strong tag, hidden input, or text input.
+		$this->assertStringNotContainsString( '<strong>', $actual_html );
+		$this->assertStringNotContainsString( 'type="hidden"', $actual_html );
+		$this->assertStringNotContainsString( 'type="text"', $actual_html );
+	}
+
+	/**
+	 * Test that country field with multiple countries has "for" attribute.
+	 */
+	public function test_country_field_multiple_countries() {
+		// Mock WC()->countries to return multiple countries.
+		$mock_countries = $this->getMockBuilder( WC_Countries::class )
+			->onlyMethods( array( 'get_allowed_countries' ) )
+			->getMock();
+
+		$mock_countries->method( 'get_allowed_countries' )
+			->willReturn(
+				array(
+					'US' => 'United States',
+					'CA' => 'Canada',
+				)
+			);
+
+		// Store original countries object.
+		$original_countries = WC()->countries;
+		WC()->countries     = $mock_countries;
+
+		$actual_html = woocommerce_form_field(
+			'billing_country',
+			array(
+				'type'   => 'country',
+				'id'     => 'billing_country',
+				'label'  => 'Country / Region',
+				'return' => true,
+			),
+			'US'
+		);
+
+		// Restore original countries object.
+		WC()->countries = $original_countries;
+
+		// Should contain label with "for" attribute.
+		$this->assertStringContainsString( 'for="billing_country"', $actual_html );
+		$this->assertStringContainsString( 'Country / Region', $actual_html );
+		// Should contain select dropdown.
+		$this->assertStringContainsString( '<select', $actual_html );
+		$this->assertStringNotContainsString( 'type="hidden"', $actual_html );
+	}
 }