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