Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions projects/packages/forms/changelog/add-other-option-to-radio
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
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.
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The changelog entry should be shorter and more concise. Consider simplifying to: "Add 'Other' option support for radio fields with custom text input." The additional details about ARIA accessibility, validation, and metadata storage are implementation details that don't need to be in the user-facing changelog.

Copilot generated this review using guidance from repository custom instructions.
24 changes: 24 additions & 0 deletions projects/packages/forms/src/blocks/contact-form/editor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1225,3 +1225,27 @@
}
}
}

/* "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.
Copy link

Copilot AI Jan 20, 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 logical properties to be RTL-aware by default. Replace margin-left: 1.5em; with margin-inline-start: 1.5em; to ensure proper spacing in both LTR and RTL layouts. See https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Logical_Properties

Copilot generated this review using guidance from repository custom instructions.
}

.jetpack-other-text-input {
width: 100%;
max-width: 400px;
padding: 0.5em;
border: var(--jetpack--contact-form--border, 1px solid #8c8f94);
border-radius: var(--jetpack--contact-form--border-radius, 0);
font-size: var(--jetpack--contact-form--font-size, 16px);

&:focus {
outline: 2px solid;
outline-offset: 2px;
}

&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
79 changes: 78 additions & 1 deletion projects/packages/forms/src/blocks/field-single-choice/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
useBlockProps,
useInnerBlocksProps,
} from '@wordpress/block-editor';
import { useSelect } from '@wordpress/data';
import { createBlock } from '@wordpress/blocks';
import { ToggleControl } from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import clsx from 'clsx';
import JetpackFieldControls from '../shared/components/jetpack-field-controls.js';
Expand All @@ -19,6 +21,7 @@ export default function SingleChoiceFieldEdit( props ) {
select => select( blockEditorStore ).getBlock( clientId ).innerBlocks,
[ clientId ]
);
const { insertBlock, removeBlock } = useDispatch( blockEditorStore );
const options = innerBlocks?.[ 1 ]?.innerBlocks;
const classes = clsx( className, 'jetpack-field jetpack-field-multiple', {
'is-selected': isSelected,
Expand All @@ -41,6 +44,79 @@ export default function SingleChoiceFieldEdit( props ) {
templateLock: 'all',
} );

const hasOtherOption = useSelect(
select => {
const block = select( blockEditorStore ).getBlock( clientId );
if ( ! block || ! block.innerBlocks || block.innerBlocks.length < 2 ) {
return false;
}
// Get the options container block (second inner block)
const optionsBlock = block.innerBlocks[ 1 ];
if ( ! optionsBlock || ! optionsBlock.innerBlocks ) {
return false;
}
// Check if any option block has isOther attribute set to true
return optionsBlock.innerBlocks.some(
innerBlock => innerBlock?.attributes?.isOther === true
);
},
[ clientId ]
);

const extraFieldSettings = [
{
element: (
<ToggleControl
key="allowOther"
label={ __( 'Include "Other" option', 'jetpack-forms' ) }
checked={ !! hasOtherOption }
onChange={ toggleValue => {
// Find the options container block (second inner block)
const optionsBlock = innerBlocks?.[ 1 ];
if ( ! optionsBlock ) {
return;
}

if ( toggleValue ) {
// If an "Other" option already exists, do nothing.
if ( hasOtherOption ) {
return;
}

const newOption = createBlock( 'jetpack/option', {
label: __( 'Other', 'jetpack-forms' ),
isOther: true,
} );

insertBlock(
newOption,
optionsBlock.innerBlocks.length,
optionsBlock.clientId,
false // Don't update block selection
);
} else {
// Remove any existing "Other" option blocks.
optionsBlock.innerBlocks.forEach( b => {
if ( b?.attributes?.isOther ) {
removeBlock(
b.clientId,
false // Don't update block selection
);
}
} );
}
} }
help={ __(
'Include an "Other" option with a text input field below it',
'jetpack-forms'
) }
__nextHasNoMarginBottom={ true }
/>
),
index: 2,
},
];

return (
<>
<div { ...innerBlockProps } />
Expand All @@ -53,6 +129,7 @@ export default function SingleChoiceFieldEdit( props ) {
type={ 'radio' }
width={ width }
hidePlaceholder
extraFieldSettings={ extraFieldSettings }
/>
</>
);
Expand Down
179 changes: 129 additions & 50 deletions projects/packages/forms/src/blocks/option/edit.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { RichText, store as blockEditorStore, useBlockProps } from '@wordpress/block-editor';
import {
InspectorControls,
RichText,
store as blockEditorStore,
useBlockProps,
} from '@wordpress/block-editor';
import { ToggleControl, PanelBody, VisuallyHidden } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import clsx from 'clsx';
import { useSyncedAttributes } from '../shared/hooks/use-synced-attributes.js';
import { ALLOWED_FORMATS } from '../shared/util/constants.js';
import useEnter from './use-enter.js';
Expand All @@ -17,14 +25,23 @@ const getLabelOrFallback = ( label, placeholder ) => {
return label ?? placeholder;
};

const OptionEdit = ( { attributes, clientId, context, name, setAttributes, mergeBlocks } ) => {
const OptionEdit = ( {
attributes,
clientId,
context,
isSelected,
mergeBlocks,
name,
setAttributes,
} ) => {
const {
'jetpack/field-default-value': defaultValue,
'jetpack/field-options-type': type = 'checkbox',
'jetpack/field-required': required,
'jetpack/field-share-attributes': isSynced,
} = context;
const { hideInput, label, isStandalone, requiredText, placeholder } = attributes;
const { hideInput, label, isStandalone, requiredText, placeholder, isOther, otherPlaceholder } =
attributes;

useSyncedAttributes( name, isSynced, SYNCED_ATTRIBUTE_KEYS, attributes, setAttributes );

Expand All @@ -37,6 +54,19 @@ const OptionEdit = ( { attributes, clientId, context, name, setAttributes, merge
[ clientId ]
);

const isParentSelected = useSelect(
select => {
const { getBlockRootClientId, getSelectedBlockClientId } = select( blockEditorStore );
const parentClientId = getBlockRootClientId( clientId );
if ( ! parentClientId ) {
return false;
}
const selectedBlockClientId = getSelectedBlockClientId();
return selectedBlockClientId === parentClientId;
},
[ clientId ]
);

const onRemove = () => {
if ( siblingsCount <= 1 ) {
return;
Expand All @@ -45,6 +75,8 @@ const OptionEdit = ( { attributes, clientId, context, name, setAttributes, merge
removeBlock( clientId );
};

const [ isFocusedOtherPlaceholder, setIsFocusedOtherPlaceholder ] = useState( false );

const blockProps = useBlockProps( {
className: `jetpack-field-option field-option-${ type }`,
} );
Expand All @@ -55,7 +87,11 @@ const OptionEdit = ( { attributes, clientId, context, name, setAttributes, merge
const isPreviewMode = useSelect( select => {
return select( blockEditorStore ).getSettings().isPreviewMode;
}, [] );
const placeholderValue = placeholder !== '' ? placeholder : __( 'Add option…', 'jetpack-forms' );
const emptyPlaceholder = isOther
? __( 'Other', 'jetpack-forms' )
: __( 'Add option…', 'jetpack-forms' );
const placeholderValue = placeholder !== '' ? placeholder : emptyPlaceholder;

// The label value to use for the RichText field must manually fall back to the
// placeholder to be rendered in previews.
const labelValue = isPreviewMode ? getLabelOrFallback( label, placeholderValue ) : label;
Expand All @@ -65,59 +101,102 @@ const OptionEdit = ( { attributes, clientId, context, name, setAttributes, merge
// to allow for custom required text.
if ( isStandalone ) {
return (
<div { ...blockProps }>
{ ! hideInput && (
<input
className="jetpack-field-option__checkbox"
checked={ !! defaultValue }
onChange={ noop }
type={ type }
/>
) }
<div className="jetpack-field-option__label-wrapper">
<RichText
ref={ useEnterRef }
identifier="label"
tagName="div"
className="wp-block"
value={ labelValue }
placeholder={ placeholderValue }
__unstableDisableFormats
onChange={ newLabel => setAttributes( { label: newLabel } ) }
onRemove={ onRemove }
/>
{ required && (
<RichText
ref={ useEnterRequiredRef }
allowedFormats={ ALLOWED_FORMATS }
className="required"
onChange={ value => setAttributes( { requiredText: value } ) }
tagName="span"
value={ requiredText || __( '(required)', 'jetpack-forms' ) }
withoutInteractiveFormatting
<>
<div { ...blockProps }>
{ ! hideInput && (
<input
className="jetpack-field-option__checkbox"
checked={ !! defaultValue }
onChange={ noop }
type={ type }
/>
) }

<div className={ clsx( 'jetpack-field-option__label-wrapper', { 'is-other': isOther } ) }>
<RichText
ref={ useEnterRef }
identifier="label"
tagName="div"
className="wp-block"
value={ labelValue }
placeholder={ placeholderValue }
__unstableDisableFormats
onChange={ newLabel => setAttributes( { label: newLabel } ) }
onRemove={ onRemove }
/>

{ required && (
<RichText
ref={ useEnterRequiredRef }
allowedFormats={ ALLOWED_FORMATS }
className="required"
onChange={ value => setAttributes( { requiredText: value } ) }
tagName="span"
value={ requiredText || __( '(required)', 'jetpack-forms' ) }
withoutInteractiveFormatting
/>
) }
</div>
</div>
</div>
</>
);
}

return (
<li { ...blockProps }>
<input type={ type } className="jetpack-option__type" tabIndex="-1" />
<RichText
ref={ useEnterRef }
identifier="label"
tagName="div"
className="wp-block"
onMerge={ mergeBlocks }
value={ labelValue }
placeholder={ __( 'Add option…', 'jetpack-forms' ) }
__unstableDisableFormats
onChange={ newLabel => setAttributes( { label: newLabel } ) }
onRemove={ onRemove }
/>
</li>
<>
<InspectorControls>
<PanelBody
title={ __( 'Settings', 'jetpack-forms' ) }
className="jetpack-contact-form__panel"
>
<ToggleControl
key="allowOther"
label={ __( '"Other" option', 'jetpack-forms' ) }
checked={ !! isOther }
onChange={ toggleValue => {
setAttributes( { isOther: toggleValue } );
} }
help={ __(
'Show as "Other" option with a text input field below it.',
'jetpack-forms'
) }
__nextHasNoMarginBottom={ true }
/>
</PanelBody>
</InspectorControls>
<li { ...blockProps }>
<input type={ type } className="jetpack-option__type" tabIndex="-1" />
<RichText
ref={ useEnterRef }
identifier="label"
tagName="div"
className="wp-block"
value={ labelValue }
placeholder={ __( 'Add option…', 'jetpack-forms' ) }
__unstableDisableFormats
onChange={ newLabel => setAttributes( { label: newLabel } ) }
onMerge={ mergeBlocks }
onRemove={ onRemove }
/>
</li>
{ isOther && ( isSelected || isParentSelected ) && (
<li className="jetpack-other-text-input-wrapper is-visible">
<VisuallyHidden as="label" htmlFor={ `${ clientId }-other-text` }>
{ otherPlaceholder || __( 'Please specify…', 'jetpack-forms' ) }
</VisuallyHidden>
<input
id={ `${ clientId }-other-text` }
className="grunion-field jetpack-field__input"
onChange={ event => setAttributes( { otherPlaceholder: event.target.value } ) }
onFocus={ () => setIsFocusedOtherPlaceholder( true ) }
onBlur={ () => setIsFocusedOtherPlaceholder( false ) }
type="text"
value={ isFocusedOtherPlaceholder ? otherPlaceholder : '' }
placeholder={ otherPlaceholder }
Comment on lines +194 to +195
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

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

The input behavior is confusing: the value is cleared on blur (line 194 shows empty string when not focused), but the placeholder shows the stored value. This creates a poor user experience where users can't see what they typed after unfocusing, and they have to refocus to edit. Consider keeping the value visible at all times by using value={ otherPlaceholder } instead of conditionally clearing it.

Suggested change
value={ isFocusedOtherPlaceholder ? otherPlaceholder : '' }
placeholder={ otherPlaceholder }
value={ otherPlaceholder }
placeholder={ otherPlaceholder || __( 'Please specify…', 'jetpack-forms' ) }

Copilot uses AI. Check for mistakes.
/>
</li>
) }
</>
);
};

Expand Down
10 changes: 10 additions & 0 deletions projects/packages/forms/src/blocks/option/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ const settings = {
type: 'boolean',
default: false,
},
isOther: {
type: 'boolean',
default: false,
},
otherPlaceholder: {
type: 'string',
default: __( 'Please specify…', 'jetpack-forms' ),
},
Comment on lines +69 to +72
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.
},
usesContext: [
'jetpack/field-default-value',
Expand All @@ -71,6 +79,8 @@ const settings = {
],
edit,
save,
__experimentalLabel: ( { isOther } ) =>
isOther ? __( 'Option (other)', 'jetpack-forms' ) : __( 'Option', 'jetpack-forms' ),
};

export default {
Expand Down
Loading
Loading