Skip to content

Conversation

@enejb
Copy link
Member

@enejb enejb commented Jan 6, 2026

Resolves FORMS-136

Introduces an 'Other' option for radio fields, allowing users to enter custom text. Includes editor UI controls, ARIA accessibility, validation, metadata storage, frontend handling, and comprehensive tests for correct processing and storage of 'Other' responses.

See

Screen.Recording.2026-01-06.at.9.52.24.AM.mov

Proposed changes:

  • Add 'Other' option support for radio fields, allowing users to provide custom text input when selecting "Other"
  • Implement ARIA accessibility attributes (aria-label, aria-describedby) for screen reader support
  • Add automatic focus management - when "Other" is selected, the text input receives focus
  • Add input clearing behavior - when switching away from "Other", the text input is automatically cleared
  • Implement proper validation to ensure custom text is provided when "Other" is selected and field is required
  • Store metadata (is_other_option, other_label, other_user_value) for flexible display in different formats
  • Add comprehensive test coverage for form submission with separate radio and text input values

Other information:

  • Have you written new tests for your changes, if applicable?
  • Have you checked the E2E test CI results, and verified that your changes do not break them?
  • Have you tested your changes on WordPress.com, if applicable (if so, you'll see a generated comment below with a script to run)?

Jetpack product discussion

Does this pull request change what data or activity we track or use?

No

Testing instructions:

  • Set up a WordPress site with Jetpack Forms installed
  • Create a new post or page and add a Jetpack Form block
  • Add a "Single Choice" (radio) field to the form
  • In the field settings, add several options (e.g., "Red", "Blue", "Green")
  • For one of the options, enable the "Allow custom text input" toggle (this marks it as the "Other" option)
  • Publish the page and view it on the frontend
  • Test the "Other" option functionality:
    • Select the "Other" option - verify the text input appears and receives focus
    • Enter some custom text (e.g., "Purple with green stripes")
    • Submit the form
    • Verify the submission shows both the "Other" label and your custom text (e.g., "Other: Purple with green stripes")
  • Test switching behavior:
    • Select the "Other" option and enter some text
    • Select a different radio option
    • Verify the text input is cleared
    • Select "Other" again and verify the input is empty
  • Test validation:
    • Mark the field as required in the field settings
    • Select the "Other" option but leave the text input empty
    • Submit the form
    • Verify a validation error appears requiring custom text to be entered
  • Test accessibility:
    • Use a screen reader to verify the "Other" text input has proper ARIA labels
    • Verify the input is announced as "Other (please specify)" or similar
  • Run the test suite: vendor/bin/phpunit --filter test_radio_field_with_other_option
  • Verify the test passes, confirming proper handling of the two-field submission pattern"--base trunk

@enejb enejb requested review from a team and Copilot January 6, 2026 17:55
@enejb enejb added [Type] Enhancement Changes to an existing feature — removing, adding, or changing parts of it [Status] Needs Review This PR is ready for review. [Package] Forms labels Jan 6, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Jan 6, 2026

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add a "[Type]" label (Bug, Enhancement, Janitorial, Task).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!

@enejb
Copy link
Member Author

enejb commented Jan 6, 2026

Some UI questions.

  1. Do we show the text input in the editor?
  2. How do users edit the placeholder for other?

Todo/check.

  • How does this look like when you export this (csv), email
  • How does it look like when you apply different form "styles" to it.

@github-actions
Copy link
Contributor

github-actions bot commented Jan 6, 2026

Are you an Automattician? The PR will need to be tested on WordPress.com. This comment will be updated with testing instructions as soon the build is complete.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds "Other" option support to radio fields in Jetpack Forms, enabling users to provide custom text when selecting an "Other" option. The implementation spans frontend interactivity, backend processing, validation, ARIA accessibility, and comprehensive test coverage.

Key Changes:

  • Adds editor UI controls for enabling/configuring "Other" options on radio fields
  • Implements frontend handling with automatic focus management and input clearing
  • Adds backend validation to ensure custom text is provided when "Other" is selected on required fields
  • Stores metadata (is_other_option, other_label, other_user_value) for flexible rendering

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
Feedback_Fields_Test.php Adds comprehensive test for "Other" option form submission and storage
view.js Implements frontend state management and event handlers for "Other" option interaction
grunion.scss Adds styling for "Other" text input with visibility control and disabled states
class-feedback.php Adds processing logic to detect and extract "Other" option metadata from submissions
class-contact-form-plugin.php Preserves isOther attribute from option blocks for server-side rendering
class-contact-form-field.php Implements validation and HTML rendering for "Other" text input with ARIA attributes
settings/index.js Adds allowOther and otherLabel block attributes
option/index.js Adds isOther attribute to option blocks
option/edit.js Adds toolbar button to mark options as "Other" with visual styling
field-single-choice/edit.js Adds toggle control to enable/disable "Other" option with automatic option block management
editor.scss Adds italic styling for "Other" options in the editor
changelog Documents the new feature addition

Comment on lines 1362 to 1365
$field .= "<input id='" . esc_attr( $radio_id ) . "' type='radio' name='" . esc_attr( $id ) . "' value='" . esc_attr( $radio_value ) . "' data-wp-on--change='actions.onOtherRadioChange' data-other-label='" . esc_attr( $option_label ) . "' " . $class . checked( $option_label, $value, false ) . ' ' . ( $required ? "required aria-required=\'true\'" : '' ) . '/> ';
$has_option_is_other_id = esc_attr( $radio_id );
} else {
$field .= "<input id='" . esc_attr( $radio_id ) . "' type='radio' name='" . esc_attr( $id ) . "' value='" . esc_attr( $radio_value ) . "' data-wp-on--change='actions.onFieldChange' " . $class . checked( $option_label, $value, false ) . ' ' . ( $required ? "required aria-required=\'true\'" : '' ) . '/> ';
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lines 1362 and 1365 are extremely long (over 200 characters each), making them difficult to read and maintain. Consider breaking these HTML concatenations into multiple lines for better readability.

Suggested change
$field .= "<input id='" . esc_attr( $radio_id ) . "' type='radio' name='" . esc_attr( $id ) . "' value='" . esc_attr( $radio_value ) . "' data-wp-on--change='actions.onOtherRadioChange' data-other-label='" . esc_attr( $option_label ) . "' " . $class . checked( $option_label, $value, false ) . ' ' . ( $required ? "required aria-required=\'true\'" : '' ) . '/> ';
$has_option_is_other_id = esc_attr( $radio_id );
} else {
$field .= "<input id='" . esc_attr( $radio_id ) . "' type='radio' name='" . esc_attr( $id ) . "' value='" . esc_attr( $radio_value ) . "' data-wp-on--change='actions.onFieldChange' " . $class . checked( $option_label, $value, false ) . ' ' . ( $required ? "required aria-required=\'true\'" : '' ) . '/> ';
$field .= "<input id='" . esc_attr( $radio_id ) . "' "
. "type='radio' name='" . esc_attr( $id ) . "' "
. "value='" . esc_attr( $radio_value ) . "' "
. "data-wp-on--change='actions.onOtherRadioChange' "
. "data-other-label='" . esc_attr( $option_label ) . "' "
. $class
. checked( $option_label, $value, false )
. ' '
. ( $required ? "required aria-required=\'true\'" : '' )
. '/> ';
$has_option_is_other_id = esc_attr( $radio_id );
} else {
$field .= "<input id='" . esc_attr( $radio_id ) . "' "
. "type='radio' name='" . esc_attr( $id ) . "' "
. "value='" . esc_attr( $radio_value ) . "' "
. "data-wp-on--change='actions.onFieldChange' "
. $class
. checked( $option_label, $value, false )
. ' '
. ( $required ? "required aria-required=\'true\'" : '' )
. '/> ';

Copilot uses AI. Check for mistakes.
id='" . $other_text_id . "'
name='" . esc_attr( $id ) . "-other-text'
type='text'
class='grunion-field'
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The class 'grunion-field' is applied to the "Other" text input but should likely include 'jetpack-other-text-input' as well for consistency with the wrapper's class name and the CSS rules defined for this element.

Suggested change
class='grunion-field'
class='grunion-field jetpack-other-text-input'

Copilot uses AI. Check for mistakes.
Comment on lines 78 to 89
<BlockControls>
<ToolbarGroup>
<ToolbarButton
onClick={ () => setAttributes( { isOther: ! isOther } ) }
className={ isOther ? 'is-pressed' : undefined }
>
{ __( 'Other', 'jetpack-forms' ) }
</ToolbarButton>
</ToolbarGroup>
</BlockControls>
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Other" toolbar button is shown for all field types (checkbox, radio) but the functionality is only implemented for radio fields. This could confuse users who see the button on non-radio field options where it won't work. Consider conditionally displaying the toolbar button only for radio field types.

Suggested change
<BlockControls>
<ToolbarGroup>
<ToolbarButton
onClick={ () => setAttributes( { isOther: ! isOther } ) }
className={ isOther ? 'is-pressed' : undefined }
>
{ __( 'Other', 'jetpack-forms' ) }
</ToolbarButton>
</ToolbarGroup>
</BlockControls>
{ type === 'radio' && (
<BlockControls>
<ToolbarGroup>
<ToolbarButton
onClick={ () => setAttributes( { isOther: ! isOther } ) }
className={ isOther ? 'is-pressed' : undefined }
>
{ __( 'Other', 'jetpack-forms' ) }
</ToolbarButton>
</ToolbarGroup>
</BlockControls>
) }

Copilot uses AI. Check for mistakes.
Comment on lines +679 to +748
public function test_radio_field_with_other_option() {
$form_id = Utility::get_form_id();

// Create form submission with separate radio value and text input value
// This simulates the actual POST data from the form
$_post_data = Utility::get_post_request(
array(
'favoritecolor' => 'Other', // Radio button value
'favoritecolor-other-text' => 'Purple with green stripes', // Text input value
),
'g' . $form_id
);

// Create options data with an "Other" option
$optionsdata = Contact_Form::esc_shortcode_val(
wp_json_encode(
array(
array(
'label' => 'Red',
),
array(
'label' => 'Blue',
),
array(
'label' => 'Other',
'isOther' => true,
),
),
JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
)
);

$shortcode = "[contact-field type='radio' label='Favorite Color' allowOther='1' options='Red,Blue,Other' optionsdata='{$optionsdata}' /]";

$form = new Contact_Form(
array(
'title' => 'Test Form',
),
$shortcode
);

$response = Feedback::from_submission( $_post_data, $form );
$feedback_post_id = $response->save();
$saved_response = Feedback::get( $feedback_post_id );

// Get the field
$fields = $response->get_fields();
$this->assertNotEmpty( $fields, 'Fields should not be empty' );

$field = reset( $fields ); // Get first field
$this->assertInstanceOf( Feedback_Field::class, $field, 'Field should be a Feedback_Field instance' );

// Test that the value is preserved
$this->assertEquals( 'Other: Purple with green stripes', $field->get_value(), 'Value should be preserved as submitted' );

// Test that metadata is set correctly
$meta = $field->get_meta();
$this->assertTrue( $meta['is_other_option'], 'is_other_option should be true' );
$this->assertEquals( 'Other', $meta['other_label'], 'other_label should be "Other"' );
$this->assertEquals( 'Purple with green stripes', $meta['other_user_value'], 'other_user_value should contain custom text' );

// Test saved response
$saved_fields = $saved_response->get_fields();
$saved_field = reset( $saved_fields );

$this->assertEquals( 'Other: Purple with green stripes', $saved_field->get_value(), 'Saved value should match' );
$saved_meta = $saved_field->get_meta();
$this->assertTrue( $saved_meta['is_other_option'], 'Saved is_other_option should be true' );
$this->assertEquals( 'Purple with green stripes', $saved_meta['other_user_value'], 'Saved other_user_value should match' );
}
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test doesn't cover the validation scenario where the "Other" option is selected on a required field but no custom text is provided. This is a critical validation path mentioned in the PR description and implemented in the validation logic (lines 434-438 of class-contact-form-field.php). Consider adding a test case that verifies the validation error is triggered when custom text is missing.

Copilot uses AI. Check for mistakes.
Comment on lines +416 to +417
field.isOtherSelected = true;
field.otherLabel = otherLabel;
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code directly mutates the field object properties (field.isOtherSelected, field.otherLabel) rather than using a proper state update mechanism. This breaks immutability principles and could lead to issues with state management, reactivity, and debugging. Consider using a proper state update function or immutable update patterns.

Copilot uses AI. Check for mistakes.
/* "Other" option text input styles */
.jetpack-other-text-input-wrapper {
display: none;
margin-left: 1.5em;
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSS uses physical properties (margin-left) instead of logical properties (margin-inline-start). For RTL language support and better internationalization, consider using logical properties as per the project's coding guidelines.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines 128 to 140
<BlockControls>
<ToolbarGroup>
<ToolbarButton
onClick={ () => setAttributes( { isOther: ! isOther } ) }
className={ isOther ? 'is-pressed' : undefined }
>
{ __( 'Other', 'jetpack-forms' ) }
</ToolbarButton>
</ToolbarGroup>
</BlockControls>
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Other" toolbar button is shown for all field types (checkbox, radio) but the functionality is only implemented for radio fields. This could confuse users who see the button on non-radio field options where it won't work. Consider conditionally displaying the toolbar button only for radio field types.

Copilot uses AI. Check for mistakes.
" . $other_input_styles . "
placeholder='" . esc_attr( __( 'Please specify...', 'jetpack-forms' ) ) . "'
value=''
aria-labelledby='" . $other_label_id . "'
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text input has aria-labelledby pointing to a label with class 'screen-reader-text', but the input is inside a region that's hidden (display: none) until the "Other" option is selected. When the region becomes visible with aria-live='polite', the screen reader might not properly announce the input's purpose. Consider adding an aria-describedby attribute that references the parent radio button's label to provide better context about what option this text input relates to.

Suggested change
aria-labelledby='" . $other_label_id . "'
aria-labelledby='" . $other_label_id . "'
aria-describedby='" . esc_attr( $has_option_is_other_id ) . "'

Copilot uses AI. Check for mistakes.
Comment on lines +74 to +82
// For radio fields, check if value uses an "Other" pattern
let isOtherSelected = false;
let otherLabel = null;
if ( type === 'radio' && value && value.includes( ': ' ) ) {
// Check if this might be an "Other" value (has the pattern "Label: text")
const colonIndex = value.indexOf( ': ' );
const possibleLabel = value.substring( 0, colonIndex );
// Simple heuristic: if the label part is short (< 30 chars), it might be an "Other" label
if ( possibleLabel.length < 30 ) {
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The heuristic for detecting "Other" options in radio fields is fragile. The code checks if a value contains ": " and if the label part is less than 30 characters to determine if it's an "Other" option. This could lead to false positives - any radio option value that happens to contain a colon followed by text with a short prefix would be incorrectly identified as an "Other" option. Consider using a more reliable mechanism, such as explicitly checking against known "Other" labels from the field configuration or using a metadata flag.

Suggested change
// For radio fields, check if value uses an "Other" pattern
let isOtherSelected = false;
let otherLabel = null;
if ( type === 'radio' && value && value.includes( ': ' ) ) {
// Check if this might be an "Other" value (has the pattern "Label: text")
const colonIndex = value.indexOf( ': ' );
const possibleLabel = value.substring( 0, colonIndex );
// Simple heuristic: if the label part is short (< 30 chars), it might be an "Other" label
if ( possibleLabel.length < 30 ) {
// For radio fields, check if value uses an explicit "Other" pattern
let isOtherSelected = false;
let otherLabel = null;
if ( type === 'radio' && typeof value === 'string' && value.includes( ': ' ) ) {
const colonIndex = value.indexOf( ': ' );
const possibleLabel = value.substring( 0, colonIndex );
// Only treat as "Other" when the label explicitly matches a known "Other" option.
if ( possibleLabel === 'Other' ) {

Copilot uses AI. Check for mistakes.
Comment on lines +391 to +393
field.isOtherSelected = false;
field.otherLabel = null;

Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code directly mutates the field object properties (field.isOtherSelected, field.otherLabel) rather than using a proper state update mechanism. This breaks immutability principles and could lead to issues with state management, reactivity, and debugging. Consider using a proper state update function or immutable update patterns.

Suggested change
field.isOtherSelected = false;
field.otherLabel = null;
const updatedField = {
...field,
isOtherSelected: false,
otherLabel: null,
};
context.fields = {
...context.fields,
[ fieldId ]: updatedField,
};

Copilot uses AI. Check for mistakes.
@jp-launch-control
Copy link

jp-launch-control bot commented Jan 6, 2026

Code Coverage Summary

Coverage changed in 7 files. Only the first 5 are listed here.

File Coverage Δ% Δ Uncovered
projects/packages/forms/src/modules/form/view.js 0/325 (0.00%) 0.00% 45 💔
projects/packages/forms/src/contact-form/class-contact-form-field.php 1092/1780 (61.35%) -1.27% 36 💔
projects/packages/forms/src/blocks/field-single-choice/edit.js 0/32 (0.00%) 0.00% 22 💔
projects/packages/forms/src/blocks/option/edit.js 0/44 (0.00%) 0.00% 13 💔
projects/packages/forms/src/contact-form/class-feedback.php 690/728 (94.78%) -0.88% 8 💔

Full summary · PHP report · JS report

If appropriate, add one of these labels to override the failing coverage check: Covered by non-unit tests Use to ignore the Code coverage requirement check when E2Es or other non-unit tests cover the code Coverage tests to be added later Use to ignore the Code coverage requirement check when tests will be added in a follow-up PR I don't care about code coverage for this PR Use this label to ignore the check for insufficient code coveage.

@simison simison force-pushed the add/other-option-to-dropdown branch from 3085195 to 0d958f6 Compare January 7, 2026 10:32
},
otherLabel: {
type: 'string',
default: 'Other',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs __()

Comment on lines 23 to 30
allowOther: {
type: 'boolean',
default: false,
},
otherLabel: {
type: 'string',
default: 'Other',
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if we could simplify by not having other options at the field level, and only have isOther at the inner option block? Once that's present, the other text field gets rendered right below it?

Comment on lines 1204 to 1206
&.is-other {
font-style: italic;
}
Copy link
Member

@simison simison Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's instead allow italic & bold styling for option field (can be separate PR) so that this is something users can customize, instead of us dictating the fixed style.

In the theme I tested this didn't even work likely due theme override.

Copilot AI review requested due to automatic review settings January 7, 2026 11:08
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.

Comment on lines +77 to +85
if ( type === 'radio' && value && value.includes( ': ' ) ) {
// Check if this might be an "Other" value (has the pattern "Label: text")
const colonIndex = value.indexOf( ': ' );
const possibleLabel = value.substring( 0, colonIndex );
// Simple heuristic: if the label part is short (< 30 chars), it might be an "Other" label
if ( possibleLabel.length < 30 ) {
isOtherSelected = true;
otherLabel = possibleLabel;
}
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The heuristic for detecting "Other" values based on a colon separator and a 30-character label length is fragile. This could incorrectly identify legitimate radio values that happen to contain a colon and short text before it (e.g., "Time: Morning", "Status: Active"). This detection should rely on more explicit metadata or field attributes rather than pattern matching the value string.

Copilot uses AI. Check for mistakes.
Comment on lines 1361 to 1366
if ( ! empty( $option['isOther'] ) ) {
$field .= "<input id='" . esc_attr( $radio_id ) . "' type='radio' name='" . esc_attr( $id ) . "' value='" . esc_attr( $radio_value ) . "' data-wp-on--change='actions.onOtherRadioChange' data-other-label='" . esc_attr( $option_label ) . "' " . $class . checked( $option_label, $value, false ) . ' ' . ( $required ? "required aria-required=\'true\'" : '' ) . '/> ';
$has_option_is_other_id = esc_attr( $radio_id );
} else {
$field .= "<input id='" . esc_attr( $radio_id ) . "' type='radio' name='" . esc_attr( $id ) . "' value='" . esc_attr( $radio_value ) . "' data-wp-on--change='actions.onFieldChange' " . $class . checked( $option_label, $value, false ) . ' ' . ( $required ? "required aria-required=\'true\'" : '' ) . '/> ';
}
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The input elements are concatenated as very long single lines (lines 1362 and 1365), making the code difficult to read and maintain. Consider breaking these into multi-line strings with proper indentation for better readability.

Suggested change
if ( ! empty( $option['isOther'] ) ) {
$field .= "<input id='" . esc_attr( $radio_id ) . "' type='radio' name='" . esc_attr( $id ) . "' value='" . esc_attr( $radio_value ) . "' data-wp-on--change='actions.onOtherRadioChange' data-other-label='" . esc_attr( $option_label ) . "' " . $class . checked( $option_label, $value, false ) . ' ' . ( $required ? "required aria-required=\'true\'" : '' ) . '/> ';
$has_option_is_other_id = esc_attr( $radio_id );
} else {
$field .= "<input id='" . esc_attr( $radio_id ) . "' type='radio' name='" . esc_attr( $id ) . "' value='" . esc_attr( $radio_value ) . "' data-wp-on--change='actions.onFieldChange' " . $class . checked( $option_label, $value, false ) . ' ' . ( $required ? "required aria-required=\'true\'" : '' ) . '/> ';
}
$on_change = 'actions.onFieldChange';
if ( ! empty( $option['isOther'] ) ) {
$on_change = 'actions.onOtherRadioChange';
$has_option_is_other_id = esc_attr( $radio_id );
}
$required_attr = $required ? " required aria-required='true'" : '';
$input_attributes = "id='" . esc_attr( $radio_id ) . "'";
$input_attributes .= " type='radio'";
$input_attributes .= " name='" . esc_attr( $id ) . "'";
$input_attributes .= " value='" . esc_attr( $radio_value ) . "'";
$input_attributes .= " data-wp-on--change='" . $on_change . "'";
if ( ! empty( $option['isOther'] ) ) {
$input_attributes .= " data-other-label='" . esc_attr( $option_label ) . "'";
}
$input_attributes .= ' ' . $class;
$input_attributes .= checked( $option_label, $value, false );
$input_attributes .= $required_attr;
$field .= '<input ' . $input_attributes . ' /> ';

Copilot uses AI. Check for mistakes.
$custom_text = trim( $custom_text );

// For required fields, ensure custom text is not empty
if ( $this->get_attribute( 'required' ) && empty( $custom_text ) ) {
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation logic at line 435 checks if the field is required using $this->get_attribute( 'required' ), but this could be inconsistent with the $isRequired context used elsewhere in validation. Consider using a consistent method to check the required status throughout the validation block.

Suggested change
if ( $this->get_attribute( 'required' ) && empty( $custom_text ) ) {
if ( $isRequired && empty( $custom_text ) ) {

Copilot uses AI. Check for mistakes.
id='" . $other_text_id . "'
name='" . esc_attr( $id ) . "-other-text'
type='text'
class='grunion-field'
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text input element is missing the jetpack-other-text-input class that's defined in the CSS (grunion.scss). The class attribute should include both grunion-field jetpack-other-text-input to ensure both the general field styles and the specific "Other" input styles are applied correctly.

Suggested change
class='grunion-field'
class='grunion-field jetpack-other-text-input'

Copilot uses AI. Check for mistakes.

// Get the text input value
const fieldset = event.target.closest( 'fieldset' );
const otherTextInput = fieldset?.querySelector( '.jetpack-other-text-input' );
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The querySelector .jetpack-other-text-input in lines 396 and 421 won't find any elements because the rendered HTML uses class grunion-field instead. This will cause the text input clearing and focusing features to fail. The class name should match what's rendered in the PHP (class-contact-form-field.php line 1434).

Suggested change
const otherTextInput = fieldset?.querySelector( '.jetpack-other-text-input' );
const otherTextInput = fieldset?.querySelector( '.grunion-field' );

Copilot uses AI. Check for mistakes.
Significance: minor
Type: added

Add 'Other' option support for radio fields with custom text input, including ARIA accessibility, proper validation, and metadata storage for form submissions
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changelog filename "add-other-option-to-dropdown" mentions "dropdown" but the feature is actually for radio fields, not dropdown/select fields. The filename should be "add-other-option-to-radio-fields" or similar to accurately reflect the feature scope.

Suggested change
Add 'Other' option support for radio fields with custom text input, including ARIA accessibility, proper validation, and metadata storage for form submissions
Add 'Other' option support for dropdown fields with custom text input, including ARIA accessibility, proper validation, and metadata storage for form submissions

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings January 7, 2026 23:12
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.

Comment on lines +1405 to +1407
if ( ! empty( $option['isOther'] ) && $this->get_attribute( 'allowother' ) ) {
$field .= $this->render_other_input_field( $radio_id, $required, $id, $this->field_styles );
}
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the else branch (lines 1374-1410), the $option variable is a string after being processed by strip_tags() on line 1378. Attempting to access $option['isOther'] as an array on line 1405 will cause a PHP error. This code path is for legacy forms that don't use optionsdata. Since the "Other" functionality requires optionsdata to work properly, this check will always fail and should be removed from this branch.

Copilot uses AI. Check for mistakes.
Comment on lines +1429 to +1459
private function render_other_input_field( $has_option_is_other_id, $required, $id, $field_styles ) {
$other_text_id = esc_attr( $has_option_is_other_id ) . '-other-text';
$other_label_id = esc_attr( $has_option_is_other_id ) . '-other-label';
$other_label_text = __( 'Please specify…', 'jetpack-forms' );
$aria_required_attr = $required ? "aria-required='true'" : '';

// Prepare styles for the text input to match other form fields
$other_input_styles = ! empty( $field_styles ) ? " style='" . esc_attr( $field_styles ) . "' " : '';

// Render text input wrapper with aria-live for screen reader announcements
$field = "<div class='jetpack-other-text-input-wrapper' data-wp-class--is-visible='state.isOtherSelected' role='region' aria-live='polite'>";

// Add a visually-hidden label for screen readers
$field .= "<label id='" . $other_label_id . "' for='" . $other_text_id . "' class='screen-reader-text'>" . esc_html( $other_label_text ) . '</label>';

$field .= "<input
id='" . $other_text_id . "'
name='" . esc_attr( $id ) . "-other-text'
type='text'
class='grunion-field'
" . $other_input_styles . "
placeholder='" . esc_attr( __( 'Please specify…', 'jetpack-forms' ) ) . "'
value=''
aria-labelledby='" . $other_label_id . "'
" . $aria_required_attr . "
data-wp-on--input='actions.onOtherTextInput'
data-wp-bind--disabled='!state.isOtherSelected'
data-wp-class--has-value='state.hasFieldValue' />";
$field .= '</div>';
return $field;
}
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indentation is inconsistent - the function body starts with tabs but should align with the rest of the class methods. The opening brace should be at the same indentation level as the 'private' keyword, and the function body should be indented one level from there.

Copilot uses AI. Check for mistakes.
<ToolbarGroup>
<ToolbarButton
onClick={ () => setAttributes( { isOther: ! isOther } ) }
className={ isOther ? 'is-pressed' : undefined }
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The className logic is inconsistent between the standalone (line 84) and non-standalone (line 136) branches. In the standalone branch, clsx is used with a conditional object { 'is-pressed': isOther }, while in the non-standalone branch it uses a ternary expression. For consistency and code maintainability, both should use the same pattern, preferably clsx with the conditional object pattern.

Suggested change
className={ isOther ? 'is-pressed' : undefined }
className={ clsx( { 'is-pressed': isOther } ) }

Copilot uses AI. Check for mistakes.
class='grunion-field'
" . $other_input_styles . "
placeholder='" . esc_attr( __( 'Please specify…', 'jetpack-forms' ) ) . "'
value=''
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When an "Other" option has a saved value like "Other: custom text", the text input field needs to be pre-populated with "custom text" so users can see and edit their previous response. Currently, the text input always renders with value='' (line 1451). The code should extract the custom text portion from $value when rendering the form with a saved "Other" response and populate it in the text input field.

Copilot uses AI. Check for mistakes.
Comment on lines +1422 to +1431
* @param string $has_option_is_other_id The ID of the "Other" option.
* @param bool $required Whether the main field is required.
* @param string $id The base ID of the main field.
* @param string $field_styles The styles to apply to the text input field.
*
* @return string The HTML for the "Other" text input field.
*/
private function render_other_input_field( $has_option_is_other_id, $required, $id, $field_styles ) {
$other_text_id = esc_attr( $has_option_is_other_id ) . '-other-text';
$other_label_id = esc_attr( $has_option_is_other_id ) . '-other-label';
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name 'has_option_is_other_id' is confusing and doesn't clearly communicate its purpose. It actually represents the radio button ID for the "Other" option, not whether the option exists. Consider renaming it to something clearer like 'other_option_id' or 'other_radio_id'.

Suggested change
* @param string $has_option_is_other_id The ID of the "Other" option.
* @param bool $required Whether the main field is required.
* @param string $id The base ID of the main field.
* @param string $field_styles The styles to apply to the text input field.
*
* @return string The HTML for the "Other" text input field.
*/
private function render_other_input_field( $has_option_is_other_id, $required, $id, $field_styles ) {
$other_text_id = esc_attr( $has_option_is_other_id ) . '-other-text';
$other_label_id = esc_attr( $has_option_is_other_id ) . '-other-label';
* @param string $other_option_id The ID of the "Other" option.
* @param bool $required Whether the main field is required.
* @param string $id The base ID of the main field.
* @param string $field_styles The styles to apply to the text input field.
*
* @return string The HTML for the "Other" text input field.
*/
private function render_other_input_field( $other_option_id, $required, $id, $field_styles ) {
$other_text_id = esc_attr( $other_option_id ) . '-other-text';
$other_label_id = esc_attr( $other_option_id ) . '-other-label';

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings January 8, 2026 21:35
@simison simison force-pushed the add/other-option-to-dropdown branch from db34e2f to 551ae97 Compare January 8, 2026 21:35
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

projects/packages/forms/src/contact-form/class-contact-form-plugin.php:686

  • The 'allowother' attribute is never set on the field when processing block attributes. While individual options have 'isOther' preserved in optionsdata, the field-level 'allowother' attribute that is checked in validation (line 390) and rendering (line 1360) is never populated. This means the "Other" option functionality will not work properly. Add logic in block_attributes_to_shortcode_attributes() to set 'allowother' to true when any option has isOther set to true.
							// Preserve isOther attribute from the option block so
							// server-side rendering can attach special handlers.
							if ( ! empty( $option['attrs']['isOther'] ) ) {
								$option_data['isOther'] = true;
							}

							if ( isset( $option_attrs['class'] ) ) {
								$option_data['class'] = $option_attrs['class'] . ' wp-block-jetpack-option';
							} else {
								$option_data['class'] = 'wp-block-jetpack-option';
							}

							if ( isset( $option_attrs['style'] ) ) {
								$option_data['style'] = $option_attrs['style'];
							}

							$options[]      = $option_label; // Legacy shortcode attribute in case filters are using it.
							$options_data[] = $option_data;
						}
					}

					$atts['options']     = implode( ',', $options );
					$atts['optionsdata'] = \wp_json_encode( $options_data, JSON_UNESCAPED_SLASHES | JSON_HEX_AMP );

Comment on lines +74 to +84
// For radio fields, check if value uses an "Other" pattern
let isOtherSelected = false;
let otherLabel = null;
if ( type === 'radio' && value && value.includes( ': ' ) ) {
// Check if this might be an "Other" value (has the pattern "Label: text")
const colonIndex = value.indexOf( ': ' );
const possibleLabel = value.substring( 0, colonIndex );
// Simple heuristic: if the label part is short (< 30 chars), it might be an "Other" label
if ( possibleLabel.length < 30 ) {
isOtherSelected = true;
otherLabel = possibleLabel;
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The heuristic for detecting "Other" values (checking if label before ": " is < 30 characters) is fragile and could lead to false positives. For example, legitimate radio values like "Product: Widget" or "Color: Blue" could be misinterpreted as "Other" responses. Consider using a more reliable approach, such as checking against the actual options data to determine if an option has isOther set, or storing an explicit flag when "Other" is selected.

Suggested change
// For radio fields, check if value uses an "Other" pattern
let isOtherSelected = false;
let otherLabel = null;
if ( type === 'radio' && value && value.includes( ': ' ) ) {
// Check if this might be an "Other" value (has the pattern "Label: text")
const colonIndex = value.indexOf( ': ' );
const possibleLabel = value.substring( 0, colonIndex );
// Simple heuristic: if the label part is short (< 30 chars), it might be an "Other" label
if ( possibleLabel.length < 30 ) {
isOtherSelected = true;
otherLabel = possibleLabel;
// For radio fields, determine if an "Other" option is selected based on the field options.
let isOtherSelected = false;
let otherLabel = null;
if (
type === 'radio' &&
typeof value === 'string' &&
extra &&
Array.isArray( extra.options )
) {
const matchingOtherOption = extra.options.find(
option =>
option &&
option.isOther &&
typeof option.label === 'string' &&
value.startsWith( option.label + ': ' )
);
if ( matchingOtherOption ) {
isOtherSelected = true;
otherLabel = matchingOtherOption.label;

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +68
otherPlaceholder: {
type: 'string',
default: __( 'Please specify…', 'jetpack-forms' ),
},
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The otherPlaceholder attribute is defined in the block schema but is never actually used in the frontend rendering. The frontend always uses the hardcoded string "Please specify…" from the translation function. Consider either removing the otherPlaceholder attribute if customization isn't needed, or pass it through to the frontend rendering in render_other_input_field().

Suggested change
otherPlaceholder: {
type: 'string',
default: __( 'Please specify…', 'jetpack-forms' ),
},

Copilot uses AI. Check for mistakes.

/* "Other" option text input styles */
.jetpack-other-text-input-wrapper {
margin-left: 1.5em;
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The margin-left property should use the logical property margin-inline-start instead to support RTL languages. This ensures the margin is applied correctly in both LTR and RTL layouts.

Copilot generated this review using guidance from repository custom instructions.
Introduces an 'Other' option for radio fields, allowing users to enter custom text. Includes editor UI controls, ARIA accessibility, validation, metadata storage, frontend handling, and comprehensive tests for correct processing and storage of 'Other' responses.
@simison simison force-pushed the add/other-option-to-dropdown branch from 551ae97 to 7561d62 Compare January 9, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Block] Contact Form Form block (also see Contact Form label) [Feature] Contact Form [Package] Forms [Status] Needs Review This PR is ready for review. [Tests] Includes Tests [Type] Enhancement Changes to an existing feature — removing, adding, or changing parts of it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants