Commit 04789354c04 for woocommerce

commit 04789354c046f36835f629b7e3ccfc66d4173f17
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date:   Thu Jun 25 11:31:13 2026 +0100

    Fix Settings UI number field custom attributes (#65917)

    * Fix number field custom attributes

    * Add number custom attribute coverage

diff --git a/packages/js/settings-ui/changelog/fix-number-custom-attributes b/packages/js/settings-ui/changelog/fix-number-custom-attributes
new file mode 100644
index 00000000000..1b9b934da6b
--- /dev/null
+++ b/packages/js/settings-ui/changelog/fix-number-custom-attributes
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Honor `disabled` and `placeholder` custom attributes on number fields.
diff --git a/packages/js/settings-ui/src/native-fields.tsx b/packages/js/settings-ui/src/native-fields.tsx
index 23b249ce156..86564dced17 100644
--- a/packages/js/settings-ui/src/native-fields.tsx
+++ b/packages/js/settings-ui/src/native-fields.tsx
@@ -45,6 +45,42 @@ const toStringValue = ( value: SettingsValue ) =>
 const isTextInputType = ( type: string ): type is TextInputType =>
 	textInputTypes.includes( type as TextInputType );

+// Use HTML boolean attribute presence semantics: disabled="false" still
+// means disabled, while a boolean false remains false.
+const toPresenceBooleanCustomAttribute = (
+	value: string | number | boolean | undefined
+): boolean | undefined => {
+	if ( typeof value === 'undefined' ) {
+		return undefined;
+	}
+
+	return typeof value === 'boolean' ? value : true;
+};
+
+const toStringCustomAttribute = (
+	value: string | number | undefined
+): string | undefined => {
+	return typeof value === 'undefined' ? undefined : String( value );
+};
+
+const getNumberInputAttributes = (
+	customAttributes?: Record< string, string | number | boolean >
+) => {
+	const safeAttributes =
+		customAttributes && typeof customAttributes === 'object'
+			? customAttributes
+			: {};
+	const { disabled, placeholder, ...inputAttributes } = safeAttributes;
+	const placeholderAttribute =
+		typeof placeholder === 'boolean' ? undefined : placeholder;
+
+	return {
+		disabled: toPresenceBooleanCustomAttribute( disabled ),
+		placeholder: toStringCustomAttribute( placeholderAttribute ),
+		inputAttributes,
+	};
+};
+
 const getHelp = ( description?: string ) =>
 	description ? (
 		<span
@@ -152,16 +188,18 @@ export const NativeSettingsField = ( {
 	}

 	if ( field.type === 'number' ) {
+		const numberInput = getNumberInputAttributes( field.customAttributes );
+
 		return (
 			<NumberSpinControl
 				id={ field.id }
 				label={ field.label }
 				help={ getHelp( field.description ) }
 				value={ toStringValue( value ) }
-				placeholder={ field.placeholder }
-				disabled={ field.disabled }
+				placeholder={ field.placeholder ?? numberInput.placeholder }
+				disabled={ field.disabled ?? numberInput.disabled }
 				onChange={ onChange }
-				inputAttributes={ field.customAttributes }
+				inputAttributes={ numberInput.inputAttributes }
 			/>
 		);
 	}
diff --git a/packages/js/settings-ui/src/test/native-fields.test.tsx b/packages/js/settings-ui/src/test/native-fields.test.tsx
index 78470af2542..bbef10c7bc8 100644
--- a/packages/js/settings-ui/src/test/native-fields.test.tsx
+++ b/packages/js/settings-ui/src/test/native-fields.test.tsx
@@ -126,6 +126,107 @@ describe( 'NativeSettingsField', () => {
 			).toBeInstanceOf( HTMLButtonElement );
 		} );

+		it( 'honors placeholder and disabled custom attributes for number inputs', () => {
+			const container = render(
+				<NativeSettingsField
+					{ ...makeProps(
+						{
+							...numberField,
+							customAttributes: {
+								...numberField.customAttributes,
+								disabled: 'true',
+								placeholder: 'Only configurable in code',
+							},
+						},
+						''
+					) }
+				/>
+			);
+
+			const input = container.querySelector( 'input[type="number"]' );
+			expect( input ).toBeInstanceOf( HTMLInputElement );
+			expect( input ).toHaveAttribute(
+				'placeholder',
+				'Only configurable in code'
+			);
+			expect( input?.getAttribute( 'min' ) ).toBe( '0' );
+			expect( input?.getAttribute( 'step' ) ).toBe( '1' );
+			expect( ( input as HTMLInputElement ).disabled ).toBe( true );
+			expect(
+				isSpinButtonDisabled(
+					getSpinButton( container, 'Increment Low stock threshold' )
+				)
+			).toBe( true );
+			expect(
+				isSpinButtonDisabled(
+					getSpinButton( container, 'Decrement Low stock threshold' )
+				)
+			).toBe( true );
+		} );
+
+		it( 'uses presence semantics for disabled custom attributes on number inputs', () => {
+			const container = render(
+				<NativeSettingsField
+					{ ...makeProps(
+						{
+							...numberField,
+							customAttributes: {
+								...numberField.customAttributes,
+								disabled: 'false',
+							},
+						},
+						'5'
+					) }
+				/>
+			);
+
+			const input = container.querySelector( 'input[type="number"]' );
+			expect( input ).toBeInstanceOf( HTMLInputElement );
+			expect( ( input as HTMLInputElement ).disabled ).toBe( true );
+			expect(
+				isSpinButtonDisabled(
+					getSpinButton( container, 'Increment Low stock threshold' )
+				)
+			).toBe( true );
+			expect(
+				isSpinButtonDisabled(
+					getSpinButton( container, 'Decrement Low stock threshold' )
+				)
+			).toBe( true );
+		} );
+
+		it( 'lets top-level disabled props override number input custom attributes', () => {
+			const container = render(
+				<NativeSettingsField
+					{ ...makeProps(
+						{
+							...numberField,
+							disabled: false,
+							customAttributes: {
+								...numberField.customAttributes,
+								disabled: 'true',
+							},
+						},
+						'5'
+					) }
+				/>
+			);
+
+			const input = container.querySelector( 'input[type="number"]' );
+			expect( input ).toBeInstanceOf( HTMLInputElement );
+			expect( ( input as HTMLInputElement ).disabled ).toBe( false );
+			expect(
+				isSpinButtonDisabled(
+					getSpinButton( container, 'Increment Low stock threshold' )
+				)
+			).toBe( false );
+			expect(
+				isSpinButtonDisabled(
+					getSpinButton( container, 'Decrement Low stock threshold' )
+				)
+			).toBe( false );
+		} );
+
 		it( 'calls onChange with the stepped value and announces it when a spin button is clicked', () => {
 			const onChange = jest.fn();
 			const container = render(