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(