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 );
+ }
}