Commit da009bce482 for woocommerce
commit da009bce4820d32fd114d52b657b0ecbda91782f
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date: Wed Sep 2 18:55:28 2026 +0100
Remove Woo-owned Settings UI field renderers (#67863)
* Remove the Woo-owned field renderers
* Update changelog entries for DataForm controls
* refactor(settings-ui): drop the unused createElement import
Removing the info wrapper left the DataForm adapter with no JSX, so the
createElement pragma import no longer has a use and tsc flags it under
noUnusedLocals.
Refs WOOPRD-3597
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015D8kcBNBYzsRvGjLDWQ8xg
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
diff --git a/docs/extensions/settings-and-config/settings-ui.md b/docs/extensions/settings-and-config/settings-ui.md
index a68e6aa1692..3382c7f0c68 100644
--- a/docs/extensions/settings-and-config/settings-ui.md
+++ b/docs/extensions/settings-and-config/settings-ui.md
@@ -49,7 +49,7 @@ add_filter(
A `WC_Settings_Page` subclass opts in by returning a settings UI adapter from `get_settings_ui_page()`.
-For pages that only need native fields, use `LegacySettingsPageAdapter`:
+For pages that only need built-in fields, use `LegacySettingsPageAdapter`:
```php
<?php
@@ -139,9 +139,9 @@ WooCommerce creates the settings UI adapter for registered sections internally.
Use a section id that does not conflict with an existing section on the same settings tab. For the `checkout` tab, ids that match existing payment gateway sections are reserved.
-### Provide a native Settings UI page for a registered section
+### Provide a custom Settings UI page for a registered section
-Sections with custom navigation, save handlers, or native Settings UI schemas can provide their own Settings UI page instead of using the legacy settings adapter.
+Sections with custom navigation, save handlers, or custom Settings UI schemas can provide their own Settings UI page instead of using the legacy settings adapter.
```php
<?php
@@ -159,7 +159,7 @@ final class My_Plugin_Settings_Section extends SettingsSection {
When `get_settings_ui_page()` returns a `SettingsUIPageInterface`, WooCommerce uses it directly for the registered section. Returning `null` keeps the default behavior: WooCommerce converts the section's legacy `get_settings()` array into a Settings UI schema.
-### Section navigation on native pages
+### Section navigation on custom pages
The Settings UI shell renders sibling-section navigation from the `shell.sectionNavigation` schema key. How it applies depends on where the page is registered:
@@ -178,7 +178,7 @@ $schema['shell']['sectionNavigation'] = array(
);
```
-## Native field migration
+## DataForm field migration
The legacy adapter converts the existing `get_settings()` array into a canonical schema for React. It supports common settings fields:
@@ -332,13 +332,13 @@ $schema['shell']['badges'] = array(
The Products settings page is the Core reference migration. With `settings-ui` enabled, the Products tab renders through the settings UI. With the flag disabled, it renders through the existing legacy settings UI.
-Use this page to verify the native migration path before testing a plugin-specific page such as WooPayments.
+Use this page to verify the DataForm migration path before testing a plugin-specific page such as WooPayments.
## Testing an extension integration
1. Enable the `settings-ui` feature flag.
2. Return a settings UI adapter from your `WC_Settings_Page` subclass.
-3. Start with native fields and confirm the page renders and saves.
+3. Start with built-in fields and confirm the page renders and saves.
4. Add `component` metadata only for fields that need custom UI.
5. Register scoped JavaScript components with `registerSettingsExtension()`.
6. Return custom script handles from `get_script_handles()` so they load before mount.
diff --git a/packages/js/settings-ui/README.md b/packages/js/settings-ui/README.md
index 6921e4923e5..8328c92e2b4 100644
--- a/packages/js/settings-ui/README.md
+++ b/packages/js/settings-ui/README.md
@@ -38,4 +38,4 @@ array(
)
```
-Use native fields when possible and register custom components only for fields that need plugin-specific UI.
+Use DataForm's built-in controls when possible and register custom components only for fields that need plugin-specific UI.
diff --git a/packages/js/settings-ui/changelog/fix-number-custom-attributes b/packages/js/settings-ui/changelog/fix-number-custom-attributes
deleted file mode 100644
index 1b9b934da6b..00000000000
--- a/packages/js/settings-ui/changelog/fix-number-custom-attributes
+++ /dev/null
@@ -1,4 +0,0 @@
-Significance: patch
-Type: fix
-
-Honor `disabled` and `placeholder` custom attributes on number fields.
diff --git a/packages/js/settings-ui/changelog/fix-number-spin-control-scientific-precision b/packages/js/settings-ui/changelog/fix-number-spin-control-scientific-precision
deleted file mode 100644
index 661cf5f5c31..00000000000
--- a/packages/js/settings-ui/changelog/fix-number-spin-control-scientific-precision
+++ /dev/null
@@ -1,4 +0,0 @@
-Significance: patch
-Type: fix
-
-Clamp NumberSpinControl precision so scientific-notation steps cannot exceed toFixed limits.
diff --git a/packages/js/settings-ui/changelog/update-number-spin-control b/packages/js/settings-ui/changelog/update-number-spin-control
deleted file mode 100644
index acf938ff33a..00000000000
--- a/packages/js/settings-ui/changelog/update-number-spin-control
+++ /dev/null
@@ -1,4 +0,0 @@
-Significance: minor
-Type: update
-
-Render number fields with explicit +/- spin buttons (NumberSpinControl, composed from stable components) instead of the native browser spinner.
diff --git a/packages/js/settings-ui/changelog/update-remove-woo-field-renderers b/packages/js/settings-ui/changelog/update-remove-woo-field-renderers
new file mode 100644
index 00000000000..6b1ea102610
--- /dev/null
+++ b/packages/js/settings-ui/changelog/update-remove-woo-field-renderers
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Remove the Woo-owned field renderers now that Settings UI fields use DataForm controls.
diff --git a/packages/js/settings-ui/package.json b/packages/js/settings-ui/package.json
index 5963fb192af..ff26f5037d2 100644
--- a/packages/js/settings-ui/package.json
+++ b/packages/js/settings-ui/package.json
@@ -37,7 +37,6 @@
"dependencies": {
"@woocommerce/sanitize": "workspace:*",
"@wordpress/admin-ui": "catalog:wp-bundled",
- "@wordpress/a11y": "catalog:wp-min",
"@wordpress/components": "catalog:wp-min",
"@wordpress/dataviews": "17.1.0",
"@wordpress/element": "catalog:wp-min",
diff --git a/packages/js/settings-ui/src/dataform-adapter.tsx b/packages/js/settings-ui/src/dataform-adapter.tsx
index 420f2d8ff6e..ddfe51aefd3 100644
--- a/packages/js/settings-ui/src/dataform-adapter.tsx
+++ b/packages/js/settings-ui/src/dataform-adapter.tsx
@@ -1,7 +1,6 @@
/**
* External dependencies
*/
-import { createElement } from '@wordpress/element';
import type {
Field,
FieldTypeName,
@@ -296,15 +295,10 @@ export const buildDataFormField = (
if ( settingsField.type === 'info' ) {
field.readOnly = true;
- // DataForm paints the label for a read-only field and drops its
- // description, so info shows the sanitized element the field already
- // carries rather than sanitizing the same string again per render.
+ // The description is already a sanitized element, and DataForm paints
+ // the label for a read-only field, so info reuses it as its body.
field.render = ( { field: normalizedField } ) =>
- normalizedField.description ? (
- <div className="wc-settings-ui__info">
- { normalizedField.description }
- </div>
- ) : null;
+ normalizedField.description ?? null;
return field;
}
diff --git a/packages/js/settings-ui/src/index.ts b/packages/js/settings-ui/src/index.ts
index 4b929680235..082f55280f7 100644
--- a/packages/js/settings-ui/src/index.ts
+++ b/packages/js/settings-ui/src/index.ts
@@ -1,5 +1,4 @@
export { SettingsUIErrorBoundary, SettingsUIPage } from './settings-ui-page';
-export { NativeSettingsField } from './native-fields';
export { HiddenInputs, getHiddenInputs } from './hidden-inputs';
export {
registerSettingsExtension,
@@ -27,8 +26,6 @@ export type {
SettingsEditControlProps,
SettingsExtensionRegistration,
SettingsExtensionScope,
- SettingsFieldComponent,
- SettingsFieldComponentProps,
SettingsFieldContext,
SettingsRegionComponent,
SettingsRegionComponentProps,
diff --git a/packages/js/settings-ui/src/native-fields.tsx b/packages/js/settings-ui/src/native-fields.tsx
deleted file mode 100644
index 6af32ac3449..00000000000
--- a/packages/js/settings-ui/src/native-fields.tsx
+++ /dev/null
@@ -1,262 +0,0 @@
-/**
- * External dependencies
- */
-import {
- BaseControl,
- CheckboxControl,
- SelectControl,
- TextControl,
- TextareaControl,
-} from '@wordpress/components';
-import { createElement, RawHTML } from '@wordpress/element';
-import { __ } from '@wordpress/i18n';
-
-/**
- * Internal dependencies
- */
-import { warn } from './diagnostics';
-import {
- createSettingsHelpElement as getHelp,
- sanitizeSettingsHtml,
-} from './html';
-import { NumberSpinControl } from './number-spin-control';
-import type { SettingsFieldComponentProps, SettingsValue } from './types';
-
-type TextInputType =
- | 'text'
- | 'password'
- | 'datetime-local'
- | 'date'
- | 'time'
- | 'email'
- | 'url'
- | 'tel';
-
-const textInputTypes: TextInputType[] = [
- 'text',
- 'password',
- 'datetime-local',
- 'date',
- 'time',
- 'email',
- 'url',
- 'tel',
-];
-
-const nativeFieldTypes = new Set( [
- 'info',
- 'checkbox',
- 'textarea',
- 'select',
- 'radio',
- 'array',
- 'number',
-] );
-
-const toStringValue = ( value: SettingsValue ) =>
- value === null || typeof value === 'undefined' ? '' : String( value );
-
-const isTextInputType = ( type: string ): type is TextInputType =>
- textInputTypes.includes( type as TextInputType );
-
-export const isNativeSettingsFieldType = ( type: string ) =>
- nativeFieldTypes.has( type ) || isTextInputType( type );
-
-// 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,
- };
-};
-
-export const NativeSettingsField = ( {
- field,
- value,
- onChange,
-}: SettingsFieldComponentProps ) => {
- if ( field.type === 'info' ) {
- return (
- <div className="wc-settings-ui__info" id={ field.id }>
- <strong>{ field.label }</strong>
- { field.description ? (
- <RawHTML>
- { sanitizeSettingsHtml( field.description ) }
- </RawHTML>
- ) : null }
- </div>
- );
- }
-
- if ( field.type === 'checkbox' ) {
- return (
- <CheckboxControl
- className="wc-settings-ui__control"
- label={ field.label }
- help={ getHelp( field.description ) }
- checked={ value === true || value === 'yes' || value === '1' }
- disabled={ field.disabled }
- onChange={ onChange }
- __nextHasNoMarginBottom
- />
- );
- }
-
- if ( field.type === 'textarea' ) {
- return (
- <TextareaControl
- className="wc-settings-ui__control"
- label={ field.label }
- help={ getHelp( field.description ) }
- value={ toStringValue( value ) }
- placeholder={ field.placeholder }
- disabled={ field.disabled }
- onChange={ onChange }
- __nextHasNoMarginBottom
- />
- );
- }
-
- if ( field.type === 'select' || field.type === 'radio' ) {
- const selectValue = toStringValue( value );
- const options = Array.isArray( field.options ) ? field.options : [];
- const selectOptions = options.some(
- ( option ) => option.value === selectValue
- )
- ? options
- : [
- {
- value: selectValue,
- label: __( 'Select', 'woocommerce' ),
- disabled: true,
- },
- ...options,
- ];
-
- return (
- <SelectControl
- className="wc-settings-ui__control"
- label={ field.label }
- help={ getHelp( field.description ) }
- value={ selectValue }
- options={ selectOptions }
- disabled={ field.disabled }
- onChange={ ( nextValue ) => onChange( nextValue ) }
- __next40pxDefaultSize
- __nextHasNoMarginBottom
- />
- );
- }
-
- if ( field.type === 'array' ) {
- const selectedValues = Array.isArray( value ) ? value : [];
-
- return (
- <BaseControl
- className="wc-settings-ui__control"
- id={ field.id }
- label={ field.label }
- help={ getHelp( field.description ) }
- __nextHasNoMarginBottom
- >
- <select
- id={ field.id }
- multiple
- disabled={ field.disabled }
- value={ selectedValues }
- onChange={ ( event ) => {
- onChange(
- Array.from(
- event.currentTarget.selectedOptions
- ).map( ( option ) => option.value )
- );
- } }
- >
- { ( field.options || [] ).map( ( option ) => (
- <option key={ option.value } value={ option.value }>
- { option.label }
- </option>
- ) ) }
- </select>
- </BaseControl>
- );
- }
-
- 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 ?? numberInput.placeholder }
- disabled={ field.disabled ?? numberInput.disabled }
- onChange={ onChange }
- inputAttributes={ numberInput.inputAttributes }
- />
- );
- }
-
- if ( isTextInputType( field.type ) ) {
- return (
- <TextControl
- className="wc-settings-ui__control"
- type={ field.type }
- label={ field.label }
- help={ getHelp( field.description ) }
- value={ toStringValue( value ) }
- placeholder={ field.placeholder }
- disabled={ field.disabled }
- onChange={ onChange }
- __next40pxDefaultSize
- __nextHasNoMarginBottom
- { ...field.customAttributes }
- />
- );
- }
-
- warn( `Field type "${ field.type }" is not supported.`, { field } );
-
- return (
- <TextControl
- className="wc-settings-ui__control"
- label={ field.label }
- help={ getHelp( field.description ) }
- value={ toStringValue( value ) }
- disabled={ field.disabled }
- onChange={ onChange }
- __next40pxDefaultSize
- __nextHasNoMarginBottom
- />
- );
-};
diff --git a/packages/js/settings-ui/src/number-spin-control.tsx b/packages/js/settings-ui/src/number-spin-control.tsx
deleted file mode 100644
index b93dfb34138..00000000000
--- a/packages/js/settings-ui/src/number-spin-control.tsx
+++ /dev/null
@@ -1,210 +0,0 @@
-/**
- * External dependencies
- */
-import { speak } from '@wordpress/a11y';
-import { BaseControl, Button } from '@wordpress/components';
-import { createElement } from '@wordpress/element';
-import { __, sprintf } from '@wordpress/i18n';
-import type { ReactNode } from 'react';
-
-export type NumberSpinControlProps = {
- id: string;
- label?: string;
- help?: ReactNode;
- value: string;
- placeholder?: string;
- disabled?: boolean;
- onChange: ( next: string ) => void;
- inputAttributes?: Record< string, string | number | boolean >;
-};
-
-const plusIcon = (
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- width="24"
- height="24"
- aria-hidden="true"
- focusable="false"
- >
- <path d="M11 12.5V17.5H12.5V12.5H17.5V11H12.5V6H11V11H6V12.5H11Z" />
- </svg>
-);
-
-const minusIcon = (
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- width="24"
- height="24"
- aria-hidden="true"
- focusable="false"
- >
- <path d="M7 11.25h10v1.5H7z" />
- </svg>
-);
-
-const toFiniteNumber = ( raw: unknown ): number | undefined => {
- if ( typeof raw === 'number' && Number.isFinite( raw ) ) {
- return raw;
- }
-
- if ( typeof raw === 'string' && raw.trim() !== '' ) {
- const parsed = Number( raw );
-
- if ( Number.isFinite( parsed ) ) {
- return parsed;
- }
- }
-
- return undefined;
-};
-
-const decimalPlaces = ( value: number ) => {
- const normalized = String( value ).toLowerCase();
-
- if ( normalized.includes( 'e-' ) ) {
- const [ coefficient, exponent ] = normalized.split( 'e-' );
- const coefficientDecimals = coefficient.split( '.' )[ 1 ]?.length ?? 0;
- return Number( exponent ) + coefficientDecimals;
- }
-
- const fraction = normalized.split( '.' )[ 1 ];
- return fraction ? fraction.length : 0;
-};
-
-const stepDecimals = ( ...values: number[] ) =>
- Math.max( ...values.map( decimalPlaces ) );
-
-const MAX_TO_FIXED_PRECISION = 100;
-
-/**
- * A number input with explicit +/- spin buttons, per the settings designs.
- *
- * Composed from stable @wordpress/components APIs only; the native browser
- * spinner is hidden via CSS and stepping is handled by the buttons, while
- * typing and keyboard arrows keep the native input behavior.
- */
-export const NumberSpinControl = ( {
- id,
- label,
- help,
- value,
- placeholder,
- disabled,
- onChange,
- inputAttributes,
-}: NumberSpinControlProps ) => {
- const min = toFiniteNumber( inputAttributes?.min );
- const max = toFiniteNumber( inputAttributes?.max );
- const parsedStep = toFiniteNumber( inputAttributes?.step );
- // A zero or negative step would make the buttons no-ops or invert them;
- // fall back to 1 like the native number input does for an invalid step.
- const step =
- typeof parsedStep === 'number' && parsedStep > 0 ? parsedStep : 1;
- const current = toFiniteNumber( value );
-
- const stepBy = ( direction: 1 | -1 ) => {
- let next = ( current ?? 0 ) + direction * step;
-
- if ( typeof min !== 'undefined' ) {
- next = Math.max( min, next );
- }
-
- if ( typeof max !== 'undefined' ) {
- next = Math.min( max, next );
- }
-
- const requiredPrecision = stepDecimals(
- step,
- current ?? 0,
- min ?? 0,
- max ?? 0
- );
- const precision = Math.min(
- Math.max( requiredPrecision, 0 ),
- MAX_TO_FIXED_PRECISION
- );
- const nextValue =
- requiredPrecision > MAX_TO_FIXED_PRECISION
- ? String( next )
- : String( Number( next.toFixed( precision ) ) );
-
- onChange( nextValue );
- // Focus stays on the spin button while the input updates, so the
- // new value must be announced to assistive technology explicitly.
- speak( nextValue );
- };
-
- const incrementDisabled =
- disabled ||
- ( typeof max !== 'undefined' &&
- typeof current !== 'undefined' &&
- current >= max );
- const decrementDisabled =
- disabled ||
- ( typeof min !== 'undefined' &&
- typeof current !== 'undefined' &&
- current <= min );
-
- const incrementLabel = label
- ? sprintf(
- // translators: %s: the label of the number field being stepped.
- __( 'Increment %s', 'woocommerce' ),
- label
- )
- : __( 'Increment', 'woocommerce' );
- const decrementLabel = label
- ? sprintf(
- // translators: %s: the label of the number field being stepped.
- __( 'Decrement %s', 'woocommerce' ),
- label
- )
- : __( 'Decrement', 'woocommerce' );
-
- return (
- <BaseControl
- className="wc-settings-ui__control"
- id={ id }
- label={ label }
- help={ help }
- __nextHasNoMarginBottom
- >
- <div className="wc-settings-ui__number-control">
- { /* Schema-provided attributes are spread first so they can
- never override the controlled props below. */ }
- <input
- { ...inputAttributes }
- className="wc-settings-ui__number-control-input"
- type="number"
- id={ id }
- value={ value }
- placeholder={ placeholder }
- disabled={ disabled }
- aria-describedby={ help ? `${ id }__help` : undefined }
- onChange={ ( event ) =>
- onChange( event.currentTarget.value )
- }
- />
- <div className="wc-settings-ui__number-control-spin-buttons">
- <Button
- size="small"
- icon={ plusIcon }
- label={ incrementLabel }
- disabled={ incrementDisabled }
- accessibleWhenDisabled
- onClick={ () => stepBy( 1 ) }
- />
- <Button
- size="small"
- icon={ minusIcon }
- label={ decrementLabel }
- disabled={ decrementDisabled }
- accessibleWhenDisabled
- onClick={ () => stepBy( -1 ) }
- />
- </div>
- </div>
- </BaseControl>
- );
-};
diff --git a/packages/js/settings-ui/src/test/dataform-adapter.test.tsx b/packages/js/settings-ui/src/test/dataform-adapter.test.tsx
index bb0bf9c99c2..93e66217a3e 100644
--- a/packages/js/settings-ui/src/test/dataform-adapter.test.tsx
+++ b/packages/js/settings-ui/src/test/dataform-adapter.test.tsx
@@ -165,7 +165,7 @@ describe( 'dataform adapter', () => {
expect( field.elements ).toEqual( options );
} );
- it( 'renders info fields read-only with only the sanitized description', () => {
+ it( 'renders sanitized info content without a Woo-owned control', () => {
const infoField: SettingsUIField = {
id: 'info_field',
label: 'Read this',
@@ -190,11 +190,14 @@ describe( 'dataform adapter', () => {
);
expect(
container.querySelector( '.wc-settings-ui__info' )
- ).not.toBeNull();
- expect( container.querySelector( 'script' ) ).toBeNull();
- // DataForm owns the label for read-only fields, so the render
+ ).toBeNull();
+ // DataForm owns the label for a read-only field, so the render
// contributes the description and nothing else.
expect( container.textContent ).toBe( 'Useful information.' );
+ expect( container.querySelector( 'strong' )?.textContent ).toBe(
+ 'information'
+ );
+ expect( container.querySelector( 'script' ) ).toBeNull();
} );
it.each( [
diff --git a/packages/js/settings-ui/src/test/html-rendering.test.tsx b/packages/js/settings-ui/src/test/html-rendering.test.tsx
index 3d12548a8ae..61fbd0e9296 100644
--- a/packages/js/settings-ui/src/test/html-rendering.test.tsx
+++ b/packages/js/settings-ui/src/test/html-rendering.test.tsx
@@ -363,7 +363,7 @@ describe( 'settings HTML rendering', () => {
container.remove();
} );
- it( 'sanitizes native field descriptions before rendering', () => {
+ it( 'sanitizes field descriptions before rendering', () => {
const schema: SettingsUISchema = {
id: 'test-page',
title: 'Test page',
@@ -394,7 +394,7 @@ describe( 'settings HTML rendering', () => {
container.remove();
} );
- it( 'hides fields with unmet native visibility rules', () => {
+ it( 'hides fields with unmet schema visibility rules', () => {
const schema: SettingsUISchema = {
id: 'test-page',
title: 'Test page',
@@ -1154,8 +1154,7 @@ describe( 'settings HTML rendering', () => {
).toHaveLength( 2 );
// The info description keeps sanitized markup while the group
- // description renders as plain text, so the only strong tag left is
- // the one from the info description.
+ // description and the DataForm field label render as plain text.
const strongTexts = Array.from(
container.querySelectorAll( 'strong' )
).map( ( el ) => el.textContent );
diff --git a/packages/js/settings-ui/src/test/native-fields.test.tsx b/packages/js/settings-ui/src/test/native-fields.test.tsx
deleted file mode 100644
index 6519c02becb..00000000000
--- a/packages/js/settings-ui/src/test/native-fields.test.tsx
+++ /dev/null
@@ -1,559 +0,0 @@
-/**
- * External dependencies
- */
-/* global HTMLSelectElement */
-import { speak } from '@wordpress/a11y';
-import { createElement } from '@wordpress/element';
-import { act } from 'react';
-import { createRoot } from 'react-dom/client';
-
-/**
- * Internal dependencies
- */
-import {
- isNativeSettingsFieldType,
- NativeSettingsField,
-} from '../native-fields';
-import type {
- SettingsFieldComponentProps,
- SettingsUIField,
- SettingsValue,
-} from '../types';
-
-jest.mock( '@wordpress/a11y', () => ( {
- speak: jest.fn(),
-} ) );
-
-const previousActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT;
-globalThis.IS_REACT_ACT_ENVIRONMENT = true;
-
-afterAll( () => {
- globalThis.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
-} );
-
-const renderElement = ( element: JSX.Element ) => {
- const container = document.createElement( 'div' );
- document.body.appendChild( container );
- const root = createRoot( container );
-
- act( () => {
- root.render( element );
- } );
-
- return { container, root };
-};
-
-const makeProps = (
- field: SettingsUIField,
- value: SettingsValue,
- onChange: ( next: SettingsValue ) => void = () => {}
-): SettingsFieldComponentProps => ( {
- field,
- value,
- onChange,
- values: { [ field.id ]: value },
- initialValues: { [ field.id ]: value },
- setValue: () => {},
- setValues: () => {},
- context: { page: 'test-page' },
-} );
-
-describe( 'NativeSettingsField', () => {
- let cleanup: ( () => void ) | null = null;
-
- afterEach( () => {
- cleanup?.();
- cleanup = null;
- } );
-
- const render = ( element: JSX.Element ) => {
- const { container, root } = renderElement( element );
- cleanup = () => {
- act( () => {
- root.unmount();
- } );
- container.remove();
- };
- return container;
- };
-
- const clickButton = ( button: HTMLElement ) => {
- act( () => {
- button.dispatchEvent(
- new MouseEvent( 'click', { bubbles: true } )
- );
- } );
- };
-
- const getSpinButton = ( container: HTMLElement, ariaLabel: string ) => {
- const button = container.querySelector(
- `button[aria-label="${ ariaLabel }"]`
- );
-
- if ( ! ( button instanceof HTMLButtonElement ) ) {
- throw new Error(
- `Expected a spin button labelled "${ ariaLabel }".`
- );
- }
-
- return button;
- };
-
- // Spin buttons stay perceivable when disabled (accessibleWhenDisabled),
- // so the disabled state surfaces as aria-disabled, not [disabled].
- const isSpinButtonDisabled = ( button: HTMLButtonElement ) =>
- button.disabled || button.getAttribute( 'aria-disabled' ) === 'true';
-
- describe( 'number fields', () => {
- const numberField: SettingsUIField = {
- id: 'wc_test_number',
- label: 'Low stock threshold',
- type: 'number',
- customAttributes: { min: 0, step: 1 },
- };
-
- it( 'renders a number input with custom spin buttons instead of native spinners', () => {
- const container = render(
- <NativeSettingsField { ...makeProps( numberField, '5' ) } />
- );
-
- const input = container.querySelector( 'input[type="number"]' );
- expect( input ).not.toBeNull();
- expect( input?.getAttribute( 'min' ) ).toBe( '0' );
- expect( input?.getAttribute( 'step' ) ).toBe( '1' );
-
- expect(
- getSpinButton( container, 'Increment Low stock threshold' )
- ).toBeInstanceOf( HTMLButtonElement );
- expect(
- getSpinButton( container, 'Decrement Low stock threshold' )
- ).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(
- <NativeSettingsField
- { ...makeProps( numberField, '5', onChange ) }
- />
- );
-
- clickButton(
- getSpinButton( container, 'Increment Low stock threshold' )
- );
-
- expect( onChange ).toHaveBeenCalledWith( '6' );
- expect( speak ).toHaveBeenCalledWith( '6' );
- } );
-
- it( 'disables the decrement button at the minimum value', () => {
- const container = render(
- <NativeSettingsField { ...makeProps( numberField, '0' ) } />
- );
-
- expect(
- isSpinButtonDisabled(
- getSpinButton( container, 'Decrement Low stock threshold' )
- )
- ).toBe( true );
- expect(
- isSpinButtonDisabled(
- getSpinButton( container, 'Increment Low stock threshold' )
- )
- ).toBe( false );
- } );
-
- it( 'disables the increment button at the maximum value', () => {
- const container = render(
- <NativeSettingsField
- { ...makeProps(
- {
- ...numberField,
- customAttributes: { min: 0, max: 10, step: 1 },
- },
- '10'
- ) }
- />
- );
-
- expect(
- isSpinButtonDisabled(
- getSpinButton( container, 'Increment Low stock threshold' )
- )
- ).toBe( true );
- expect(
- isSpinButtonDisabled(
- getSpinButton( container, 'Decrement Low stock threshold' )
- )
- ).toBe( false );
- } );
-
- it( 'falls back to a step of 1 when the schema provides a non-positive step', () => {
- const onChange = jest.fn();
- const container = render(
- <NativeSettingsField
- { ...makeProps(
- {
- ...numberField,
- customAttributes: { min: 0, step: 0 },
- },
- '5',
- onChange
- ) }
- />
- );
-
- clickButton(
- getSpinButton( container, 'Increment Low stock threshold' )
- );
-
- expect( onChange ).toHaveBeenCalledWith( '6' );
- } );
-
- it( 'clamps stepping to the maximum and avoids float precision drift', () => {
- const onChange = jest.fn();
- const container = render(
- <NativeSettingsField
- { ...makeProps(
- {
- ...numberField,
- customAttributes: { min: 0, max: 0.3, step: 0.2 },
- },
- '0.1',
- onChange
- ) }
- />
- );
-
- clickButton(
- getSpinButton( container, 'Increment Low stock threshold' )
- );
-
- expect( onChange ).toHaveBeenCalledWith( '0.3' );
- } );
-
- it( 'preserves current value precision when it exceeds step precision', () => {
- const onChange = jest.fn();
- const container = render(
- <NativeSettingsField
- { ...makeProps(
- {
- ...numberField,
- customAttributes: { min: 0, step: 0.1 },
- },
- '0.05',
- onChange
- ) }
- />
- );
-
- clickButton(
- getSpinButton( container, 'Increment Low stock threshold' )
- );
-
- expect( onChange ).toHaveBeenCalledWith( '0.15' );
- } );
-
- it( 'handles scientific-notation steps', () => {
- const onChange = jest.fn();
- const container = render(
- <NativeSettingsField
- { ...makeProps(
- {
- ...numberField,
- customAttributes: { min: 0, step: 1e-7 },
- },
- '0',
- onChange
- ) }
- />
- );
-
- clickButton(
- getSpinButton( container, 'Increment Low stock threshold' )
- );
-
- expect( onChange ).toHaveBeenCalledWith( '1e-7' );
- } );
-
- it( 'does not exceed toFixed precision limits for tiny scientific-notation steps', () => {
- const onChange = jest.fn();
- const container = render(
- <NativeSettingsField
- { ...makeProps(
- {
- ...numberField,
- customAttributes: { min: 0, step: 1e-200 },
- },
- '0',
- onChange
- ) }
- />
- );
-
- expect( () =>
- clickButton(
- getSpinButton( container, 'Increment Low stock threshold' )
- )
- ).not.toThrow();
- expect( onChange ).toHaveBeenCalledWith( '1e-200' );
- } );
-
- it( 'steps onto the minimum from an empty value', () => {
- const onChange = jest.fn();
- const container = render(
- <NativeSettingsField
- { ...makeProps(
- {
- ...numberField,
- customAttributes: { min: 2, step: 1 },
- },
- '',
- onChange
- ) }
- />
- );
-
- clickButton(
- getSpinButton( container, 'Increment Low stock threshold' )
- );
-
- expect( onChange ).toHaveBeenCalledWith( '2' );
- } );
- } );
-
- describe( 'select fields', () => {
- it( 'renders a public select control and propagates scalar values', () => {
- const onChange = jest.fn();
- const container = render(
- <NativeSettingsField
- { ...makeProps(
- {
- id: 'wc_test_select',
- label: 'Inventory format',
- description: 'Choose how inventory is displayed.',
- type: 'select',
- options: [
- { value: 'one', label: 'One' },
- { value: 'two', label: 'Two' },
- ],
- },
- 'one',
- onChange
- ) }
- />
- );
-
- const select = container.querySelector( 'select' );
- expect( select ).toBeInstanceOf( HTMLSelectElement );
- expect( select ).toHaveValue( 'one' );
- expect( container.textContent ).toContain( 'Inventory format' );
- expect( container.textContent ).toContain(
- 'Choose how inventory is displayed.'
- );
-
- act( () => {
- if ( select instanceof HTMLSelectElement ) {
- select.value = 'two';
- select.dispatchEvent(
- new Event( 'change', {
- bubbles: true,
- cancelable: true,
- } )
- );
- }
- } );
-
- expect( onChange ).toHaveBeenCalledWith( 'two' );
- } );
-
- it.each( [
- [ 'an empty option list', [], '' ],
- [
- 'an unmatched stored value',
- [ { label: 'One', value: 'one' } ],
- 'legacy',
- ],
- ] )(
- 'keeps the labeled control for %s',
- ( _scenario, options, value ) => {
- const container = render(
- <NativeSettingsField
- { ...makeProps(
- {
- id: 'wc_test_select',
- label: 'Test select',
- type: 'select',
- options,
- },
- value
- ) }
- />
- );
-
- const select = container.querySelector( 'select' );
- expect( select ).toBeInstanceOf( HTMLSelectElement );
- expect( select ).toHaveAccessibleName( 'Test select' );
- expect( select ).toHaveValue( value );
- expect( select?.selectedOptions[ 0 ] ).toHaveTextContent(
- 'Select'
- );
- expect( select?.selectedOptions[ 0 ] ).toBeDisabled();
- }
- );
-
- it.each( [ {}, 'invalid' ] )(
- 'handles malformed non-array options without throwing',
- ( options ) => {
- const field = {
- id: 'wc_test_select',
- label: 'Test select',
- type: 'select' as const,
- options,
- } as unknown as SettingsUIField;
-
- const container = render(
- <NativeSettingsField { ...makeProps( field, 'legacy' ) } />
- );
-
- const select = container.querySelector( 'select' );
- expect( select ).toBeInstanceOf( HTMLSelectElement );
- expect( select ).toHaveValue( 'legacy' );
- expect( select?.options ).toHaveLength( 1 );
- }
- );
- } );
-
- describe( 'text fields', () => {
- it( 'renders text fields without spin buttons', () => {
- const container = render(
- <NativeSettingsField
- { ...makeProps(
- {
- id: 'wc_test_text',
- label: 'Store name',
- type: 'text',
- },
- 'hello'
- ) }
- />
- );
-
- expect(
- container.querySelector( 'input[type="text"]' )
- ).not.toBeNull();
- expect(
- container.querySelector( '.wc-settings-ui__number-control' )
- ).toBeNull();
- } );
- } );
-
- it( 'reports which field types have a native renderer', () => {
- expect( isNativeSettingsFieldType( 'text' ) ).toBe( true );
- expect( isNativeSettingsFieldType( 'select' ) ).toBe( true );
- expect( isNativeSettingsFieldType( 'extension_defined' ) ).toBe(
- false
- );
- } );
-} );
diff --git a/packages/js/settings-ui/src/types.ts b/packages/js/settings-ui/src/types.ts
index c90b3a7b88c..21a606801ed 100644
--- a/packages/js/settings-ui/src/types.ts
+++ b/packages/js/settings-ui/src/types.ts
@@ -119,21 +119,6 @@ export type SettingsFieldContext = {
section?: string;
};
-export type SettingsFieldComponentProps = {
- field: SettingsUIField;
- value: SettingsValue;
- onChange: ( value: SettingsValue ) => void;
- values: SettingsValues;
- initialValues: SettingsValues;
- setValue: ( fieldId: string, value: SettingsValue ) => void;
- setValues: ( values: Partial< SettingsValues > ) => void;
- context: SettingsFieldContext;
-};
-
-export type SettingsFieldComponent = (
- props: SettingsFieldComponentProps
-) => JSX.Element | null;
-
/**
* The field surface a registered edit control receives. A frozen subset of
* the DataForm field, so extensions do not couple to package internals.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 855134b1651..220c70a520e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2284,9 +2284,6 @@ importers:
'@woocommerce/sanitize':
specifier: workspace:*
version: link:../sanitize
- '@wordpress/a11y':
- specifier: catalog:wp-min
- version: 4.33.1
'@wordpress/admin-ui':
specifier: catalog:wp-bundled
version: 2.4.1(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.4.0)(@types/react@18.3.28)(date-fns@4.1.0)(postcss@8.5.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1(typescript@5.7.3))