Commit 96bf290339a for woocommerce
commit 96bf290339a086c3a749d05edc7e9443aa3d4bd7
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date: Tue Oct 27 10:15:03 2026 +0000
Fix missing native props in the Textarea component (#70180)
* Allow native textarea props in the public Textarea component
* Add changelog entry for textarea prop forwarding
diff --git a/plugins/woocommerce/changelog/fix-wooplug-1720-textarea-native-props b/plugins/woocommerce/changelog/fix-wooplug-1720-textarea-native-props
new file mode 100644
index 00000000000..232bab4ecc3
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-1720-textarea-native-props
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Allow extensions to use standard textarea attributes and event handlers in text areas.
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/textarea/index.tsx b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/textarea/index.tsx
index 39f0b26f275..1e95e36cb22 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/textarea/index.tsx
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/textarea/index.tsx
@@ -2,17 +2,15 @@
* External dependencies
*/
import clsx from 'clsx';
+import type { JSX, TextareaHTMLAttributes } from 'react';
/**
* Internal dependencies
*/
import './style.scss';
-export interface TextareaProps {
- className?: string;
- disabled: boolean;
+export interface TextareaProps extends TextareaHTMLAttributes< HTMLTextAreaElement > {
onTextChange: ( newText: string ) => void;
- placeholder: string;
value: string;
}
@@ -20,17 +18,22 @@ export const Textarea = ( {
className = '',
disabled = false,
onTextChange,
+ onChange,
placeholder,
+ rows = 2,
value = '',
+ ...props
}: TextareaProps ): JSX.Element => (
<textarea
+ { ...props }
className={ clsx( 'wc-block-components-textarea', className ) }
disabled={ disabled }
onChange={ ( event ) => {
onTextChange( event.target.value );
+ onChange?.( event );
} }
placeholder={ placeholder }
- rows={ 2 }
+ rows={ rows }
value={ value }
/>
);
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/textarea/stories/index.stories.tsx b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/textarea/stories/index.stories.tsx
index 48bb560d4ae..b9d22485772 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/textarea/stories/index.stories.tsx
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/textarea/stories/index.stories.tsx
@@ -3,6 +3,7 @@
*/
import type { Meta, StoryFn } from '@storybook/react-webpack5';
import { useArgs } from 'storybook/preview-api';
+import { action } from 'storybook/actions';
/**
* Internal dependencies
@@ -13,6 +14,14 @@ import '../style.scss';
export default {
title: 'External Components/Textarea',
component: Textarea,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Textarea accepts standard React textarea attributes and event handlers, including onBlur, onFocus, name, required, rows, and aria-* and data-* attributes. Continue to use value and onTextChange to control the text. onTextChange receives the new string value; an optional onChange handler receives the React change event and runs after onTextChange.',
+ },
+ },
+ },
argTypes: {
className: {
control: 'text',
@@ -51,6 +60,25 @@ export default {
description:
'The placeholder text to show when no value has been entered.',
},
+ onTextChange: {
+ control: false,
+ description:
+ 'Called with the new string value when the text changes.',
+ },
+ onChange: {
+ control: false,
+ description:
+ 'Called with the React change event after onTextChange, if provided.',
+ },
+ onBlur: {
+ control: false,
+ description:
+ 'Called with the React focus event when the textarea loses focus.',
+ },
+ rows: {
+ control: 'number',
+ description: 'Number of visible text rows. Defaults to 2.',
+ },
},
} as Meta< TextareaProps >;
@@ -75,3 +103,17 @@ Default.args = {
placeholder: 'Enter some text here',
value: '',
};
+
+export const NativeProps: StoryFn< TextareaProps > = Template.bind( {} );
+
+NativeProps.args = {
+ ...Default.args,
+ id: 'order-notes',
+ name: 'order_notes',
+ 'aria-label': 'Order notes',
+ required: true,
+ maxLength: 100,
+ rows: 4,
+ onBlur: action( 'onBlur' ),
+ onChange: action( 'onChange' ),
+};
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/textarea/test/index.tsx b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/textarea/test/index.tsx
new file mode 100644
index 00000000000..ba5959168bc
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/textarea/test/index.tsx
@@ -0,0 +1,136 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+/**
+ * Internal dependencies
+ */
+import Textarea from '..';
+
+describe( 'Textarea', () => {
+ it( 'preserves the default rows and merges custom class names', () => {
+ const { getByRole } = render(
+ <Textarea
+ className="custom-textarea"
+ onTextChange={ jest.fn() }
+ value="Initial value"
+ />
+ );
+
+ const textarea = getByRole( 'textbox' );
+ expect( textarea ).toHaveClass(
+ 'wc-block-components-textarea',
+ 'custom-textarea'
+ );
+ expect( textarea ).toHaveAttribute( 'rows', '2' );
+ expect( textarea ).toHaveValue( 'Initial value' );
+ expect( textarea ).toBeEnabled();
+ } );
+
+ it( 'forwards native, accessibility, and data attributes', () => {
+ const { getByRole } = render(
+ <Textarea
+ onTextChange={ jest.fn() }
+ value=""
+ id="order-notes"
+ name="order_notes"
+ rows={ 5 }
+ maxLength={ 100 }
+ required
+ aria-label="Order notes"
+ data-custom="notes"
+ />
+ );
+
+ const textarea = getByRole( 'textbox', { name: 'Order notes' } );
+ expect( textarea ).toHaveAttribute( 'id', 'order-notes' );
+ expect( textarea ).toHaveAttribute( 'name', 'order_notes' );
+ expect( textarea ).toHaveAttribute( 'rows', '5' );
+ expect( textarea ).toHaveAttribute( 'maxlength', '100' );
+ expect( textarea ).toBeRequired();
+ expect( textarea ).toHaveAttribute( 'data-custom', 'notes' );
+ } );
+
+ it( 'forwards focus, blur, and keyboard events', async () => {
+ const user = userEvent.setup();
+ const onFocus = jest.fn();
+ const onBlur = jest.fn();
+ const onKeyDown = jest.fn();
+ const { getByRole } = render(
+ <Textarea
+ onTextChange={ jest.fn() }
+ value=""
+ onFocus={ onFocus }
+ onBlur={ onBlur }
+ onKeyDown={ onKeyDown }
+ />
+ );
+
+ const textarea = getByRole( 'textbox' );
+ await user.click( textarea );
+ expect( onFocus ).toHaveBeenCalledWith(
+ expect.objectContaining( { target: textarea, type: 'focus' } )
+ );
+
+ await user.keyboard( '{Escape}' );
+ expect( onKeyDown ).toHaveBeenCalledWith(
+ expect.objectContaining( { target: textarea, key: 'Escape' } )
+ );
+
+ await user.tab();
+ expect( onBlur ).toHaveBeenCalledWith(
+ expect.objectContaining( { target: textarea, type: 'blur' } )
+ );
+ } );
+
+ it( 'calls onTextChange with the new text', async () => {
+ const user = userEvent.setup();
+ const onTextChange = jest.fn();
+ const { getByRole } = render(
+ <Textarea onTextChange={ onTextChange } value="Initial notes" />
+ );
+
+ await user.type( getByRole( 'textbox' ), '!' );
+
+ expect( onTextChange ).toHaveBeenCalledTimes( 1 );
+ expect( onTextChange ).toHaveBeenCalledWith( 'Initial notes!' );
+ } );
+
+ it( 'calls native onChange without replacing onTextChange', async () => {
+ const user = userEvent.setup();
+ const onTextChange = jest.fn();
+ const onChange = jest.fn();
+ const { getByRole } = render(
+ <Textarea
+ onTextChange={ onTextChange }
+ onChange={ onChange }
+ value="Initial notes"
+ />
+ );
+
+ const textarea = getByRole( 'textbox' );
+ await user.type( textarea, '!' );
+
+ expect( onTextChange ).toHaveBeenCalledTimes( 1 );
+ expect( onTextChange ).toHaveBeenCalledWith( 'Initial notes!' );
+ expect( onChange ).toHaveBeenCalledTimes( 1 );
+ expect( onChange ).toHaveBeenCalledWith(
+ expect.objectContaining( { target: textarea, type: 'change' } )
+ );
+ } );
+
+ it( 'preserves disabled and placeholder props', () => {
+ const { getByPlaceholderText } = render(
+ <Textarea
+ onTextChange={ jest.fn() }
+ value=""
+ disabled
+ placeholder="Enter notes"
+ />
+ );
+
+ expect( getByPlaceholderText( 'Enter notes' ) ).toBeDisabled();
+ } );
+} );