Commit e6f78eb3aeb for woocommerce
commit e6f78eb3aeb5e3f1b09824054b16c2c64f1b4b1c
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date: Thu Jun 18 19:06:46 2026 +0300
Settings UI: add subtitle and badges to the shell header (#65825)
* Settings UI: add subtitle and badges to the shell header
The settings UI shell exposes a title, breadcrumbs, and navigation, but the
admin-ui Page header it renders also supports a subtitle and status badges
(e.g. an "Active" pill) that extensions targeting the modernised settings
designs need. The schema had no way to set them.
Add `subtitle` and `badges` to `SettingsUIShell` and forward them to the
admin-ui Page as `subTitle` and `badges`. Badges render as intent-colored
pills (default/info/success/warning/error) styled in the settings UI
stylesheet. Both are plain text, escaped on render; the schema passes
through to the client unchanged, so no server-side change is required.
Refs WOOPMNT-6227
* refactor(settings-ui): align badge styles with design tokens and harden intent
Apply review feedback from PR #65825:
- Replace hardcoded hex in the shell badge SCSS with the existing
`--Alias-bg-bg-surface-*` / `--Alias-text-text-*` tokens (hex fallback
preserved) so colors stay consistent with `settings-payments`
`StatusBadge` and respond to theme tokens. Two side-by-side "success"
badges no longer render different greens.
- Add empty `&--default` modifier rule for symmetry with the four
non-default intents (the `default` class is emitted on every neutral
badge).
- Guard the className interpolation with a runtime intent allowlist —
TS unions erase at compile time, so an extension passing an
unrecognized string from `get_schema()` could otherwise produce an
unexpected class. Unknown intents now fall back to `default`.
- Subtitle test asserts on forwarded text content instead of the mock's
structural class.
- Match the package's test teardown convention (`root.unmount()` +
`container.remove()` at the end of each test) to avoid React root
leaks if a test throws.
- Add a coverage test for the unknown-intent fallback.
- Document that `intent` is decorative — extension authors must keep
`label` self-descriptive — and clarify the doc comment on
`SettingsUIShellBadgeIntent`.
Refs #65825
* style(settings-ui): satisfy prettier on test querySelector line
Refs #65825
* refactor(settings-ui): tighten badge intent guard and test cleanup
Round-2 review follow-ups:
- Replace the module-level `VALID_BADGE_INTENTS` Set with an inline
`getBadgeIntent()` helper using `.includes()`, matching the existing
`getActionVariant()` pattern in the same file.
- Restore `document.body.innerHTML = ''` in the test `afterEach` as a
safety net so a failing assertion before the inline `container.remove()`
doesn't leak DOM into subsequent tests.
- Drop the empty `&--default {}` SCSS block — the modifier inherits the
base `.wc-settings-ui-shell__badge` styling; an empty rule was
surface-level symmetry, not real symmetry.
Refs #65825
* fix(settings-ui): drop forward reference to nonexistent error token
No `--Alias-bg-bg-surface-error` / `--Alias-text-text-error` token
exists in the admin design-token set bundled with WooCommerce (the
admin CSS only resolves success, warning, secondary, and the base
text token). The `var()` calls landed on a forward reference that was
always hitting the hex fallback.
Use direct hex for the error intent until a real token ships — same
approach StatusBadge takes for its info intent (reusing
`--Alias-bg-bg-surface-secondary` rather than referencing a
nonexistent `--Alias-bg-bg-surface-info`).
Refs #65825
diff --git a/docs/extensions/settings-and-config/settings-ui.md b/docs/extensions/settings-and-config/settings-ui.md
index 2368629def3..56ee776eef0 100644
--- a/docs/extensions/settings-and-config/settings-ui.md
+++ b/docs/extensions/settings-and-config/settings-ui.md
@@ -185,6 +185,24 @@ array(
Descriptions are sanitized with `wp_kses_post()`. Actions are structured data with `id`, `label`, `href`, optional `variant`, optional `target`, and optional `rel`.
+## Page header
+
+A settings UI page that supplies its own schema (via `SettingsUIPageInterface::get_schema()`) can set header content through the `shell` key. Alongside `title` and `breadcrumbs`, the header supports a `subtitle` and `badges`:
+
+```php
+$schema['shell']['subtitle'] = __( 'Manage your store payment settings.', 'my-plugin' );
+$schema['shell']['badges'] = array(
+ array(
+ 'label' => __( 'Active', 'my-plugin' ),
+ 'intent' => 'success', // default | info | success | warning | error
+ ),
+);
+```
+
+`subtitle` renders under the page title. Each badge renders as a pill next to the title; `intent` selects its color. Both are plain text and are escaped on render.
+
+`intent` is decorative styling only — it conveys meaning through color. The badge `label` must be self-descriptive so screen-reader and color-blind users get the same information (e.g. prefer `"Active"` or `"Beta"` over generic text). Unknown `intent` values fall back to `default`.
+
## Reference migration in WooCommerce core
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.
diff --git a/packages/js/settings-ui/changelog/add-shell-header-fields b/packages/js/settings-ui/changelog/add-shell-header-fields
new file mode 100644
index 00000000000..4ba8e693fd3
--- /dev/null
+++ b/packages/js/settings-ui/changelog/add-shell-header-fields
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add `subtitle` and `badges` fields to the settings UI shell, forwarded to the admin-ui Page header.
diff --git a/packages/js/settings-ui/src/index.ts b/packages/js/settings-ui/src/index.ts
index 48e3815ee7f..3188af34744 100644
--- a/packages/js/settings-ui/src/index.ts
+++ b/packages/js/settings-ui/src/index.ts
@@ -19,6 +19,8 @@ export type {
SettingsUISaveStrategy,
SettingsUISchema,
SettingsUIShell,
+ SettingsUIShellBadge,
+ SettingsUIShellBadgeIntent,
SettingsUIShellBreadcrumb,
SettingsExtensionRegistration,
SettingsExtensionScope,
diff --git a/packages/js/settings-ui/src/settings-ui-page.tsx b/packages/js/settings-ui/src/settings-ui-page.tsx
index c44a4940dc7..b0993ea5d67 100644
--- a/packages/js/settings-ui/src/settings-ui-page.tsx
+++ b/packages/js/settings-ui/src/settings-ui-page.tsx
@@ -98,6 +98,15 @@ const getActionVariant = ( variant?: string ) =>
? variant
: 'secondary' ) as 'primary' | 'secondary' | 'tertiary' | 'link';
+// TS unions erase at runtime, so guard the className interpolation against unexpected
+// strings from PHP-supplied schemas.
+const getBadgeIntent = ( intent?: string ) =>
+ [ 'default', 'info', 'success', 'warning', 'error' ].includes(
+ intent || ''
+ )
+ ? intent
+ : 'default';
+
const getSaveStrategy = ( schema: SettingsUISchema ): SettingsUISaveStrategy =>
schema.save || { adapter: 'form_post' };
@@ -387,6 +396,19 @@ const ShellHeader = ( {
</nav>
) : undefined;
+ const badges = shell.badges?.length
+ ? shell.badges.map( ( badge, index ) => (
+ <span
+ className={ `wc-settings-ui-shell__badge wc-settings-ui-shell__badge--${ getBadgeIntent(
+ badge.intent
+ ) }` }
+ key={ `${ badge.label }-${ index }` }
+ >
+ { badge.label }
+ </span>
+ ) )
+ : undefined;
+
const saveButtonLabel = __( 'Save', 'woocommerce' );
const actions = showSaveButton ? (
@@ -409,7 +431,9 @@ const ShellHeader = ( {
className="wc-settings-ui-shell"
headingLevel={ 1 }
title={ title }
+ subTitle={ shell.subtitle }
breadcrumbs={ breadcrumbs }
+ badges={ badges }
actions={ actions }
showSidebarToggle={ false }
>
diff --git a/packages/js/settings-ui/src/test/header-fields.test.tsx b/packages/js/settings-ui/src/test/header-fields.test.tsx
new file mode 100644
index 00000000000..8267e492a74
--- /dev/null
+++ b/packages/js/settings-ui/src/test/header-fields.test.tsx
@@ -0,0 +1,203 @@
+/**
+ * External dependencies
+ */
+import { createElement } from '@wordpress/element';
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import type { ReactNode } from 'react';
+
+// Surface the header props the real admin-ui Page would render so the shell
+// header wiring (subtitle, badges) can be asserted.
+jest.mock( '@wordpress/admin-ui', () => ( {
+ Page: ( {
+ title,
+ subTitle,
+ breadcrumbs,
+ badges,
+ actions,
+ children,
+ className,
+ }: {
+ title?: ReactNode;
+ subTitle?: ReactNode;
+ breadcrumbs?: ReactNode;
+ badges?: ReactNode;
+ actions?: ReactNode;
+ children: ReactNode;
+ className?: string;
+ } ) => (
+ <div className={ className }>
+ <header>
+ { title }
+ { breadcrumbs }
+ { badges }
+ { subTitle && (
+ <p className="admin-ui-page__header-subtitle">
+ { subTitle }
+ </p>
+ ) }
+ { actions }
+ </header>
+ { children }
+ </div>
+ ),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import { SettingsUIPage } from '../settings-ui-page';
+import { __resetRegistry } from '../registry';
+import type { SettingsUISchema } from '../types';
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+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 baseSchema = (
+ shell: SettingsUISchema[ 'shell' ]
+): SettingsUISchema => ( {
+ id: 'test_page',
+ title: 'Test page',
+ save: { adapter: 'none' },
+ shell,
+ groups: {
+ main: {
+ id: 'main',
+ title: 'Main',
+ fields: [
+ {
+ id: 'field_a',
+ label: 'Field A',
+ type: 'text',
+ value: '',
+ save: { adapter: 'none' },
+ },
+ ],
+ },
+ },
+} );
+
+describe( 'settings UI shell header fields', () => {
+ afterEach( () => {
+ __resetRegistry();
+ // Safety net for failures between render and the inline `container.remove()`.
+ document.body.innerHTML = '';
+ } );
+
+ it( 'renders the shell subtitle', () => {
+ const { container, root } = renderElement(
+ <SettingsUIPage
+ schema={ baseSchema( {
+ title: 'Test page',
+ subtitle: 'Manage your test settings.',
+ } ) }
+ page="test_page"
+ />
+ );
+
+ // Anchor on the forwarded text, not the mock's structural class —
+ // this would catch a `subtitle` → `subTitle` mapping bug regardless of how the mock renders.
+ expect( container.textContent ).toContain(
+ 'Manage your test settings.'
+ );
+
+ act( () => root.unmount() );
+ container.remove();
+ } );
+
+ it( 'renders badges with their intent class', () => {
+ const { container, root } = renderElement(
+ <SettingsUIPage
+ schema={ baseSchema( {
+ title: 'Test page',
+ badges: [
+ { label: 'Active', intent: 'success' },
+ { label: 'Beta' },
+ ],
+ } ) }
+ page="test_page"
+ />
+ );
+
+ const badges = container.querySelectorAll(
+ '.wc-settings-ui-shell__badge'
+ );
+ expect( badges ).toHaveLength( 2 );
+ expect( badges[ 0 ].textContent ).toBe( 'Active' );
+ expect(
+ badges[ 0 ].classList.contains(
+ 'wc-settings-ui-shell__badge--success'
+ )
+ ).toBe( true );
+ // Defaults to the neutral intent when none is provided.
+ expect(
+ badges[ 1 ].classList.contains(
+ 'wc-settings-ui-shell__badge--default'
+ )
+ ).toBe( true );
+
+ act( () => root.unmount() );
+ container.remove();
+ } );
+
+ it( 'falls back to the default intent for an unknown intent value', () => {
+ const { container, root } = renderElement(
+ <SettingsUIPage
+ schema={ baseSchema( {
+ title: 'Test page',
+ // Simulate an extension passing an unrecognized intent string at runtime
+ // (TS unions are erased; PHP-supplied schemas can carry arbitrary strings).
+ badges: [
+ {
+ label: 'Mystery',
+ intent: 'magic' as never,
+ },
+ ],
+ } ) }
+ page="test_page"
+ />
+ );
+
+ const badge = container.querySelector( '.wc-settings-ui-shell__badge' );
+ expect( badge ).not.toBeNull();
+ expect(
+ badge?.classList.contains( 'wc-settings-ui-shell__badge--default' )
+ ).toBe( true );
+ expect(
+ badge?.classList.contains( 'wc-settings-ui-shell__badge--magic' )
+ ).toBe( false );
+
+ act( () => root.unmount() );
+ container.remove();
+ } );
+
+ it( 'omits subtitle and badges when not provided', () => {
+ const { container, root } = renderElement(
+ <SettingsUIPage
+ schema={ baseSchema( { title: 'Test page' } ) }
+ page="test_page"
+ />
+ );
+
+ expect(
+ container.querySelector( '.admin-ui-page__header-subtitle' )
+ ).toBeNull();
+ expect(
+ container.querySelector( '.wc-settings-ui-shell__badge' )
+ ).toBeNull();
+
+ act( () => root.unmount() );
+ container.remove();
+ } );
+} );
diff --git a/packages/js/settings-ui/src/types.ts b/packages/js/settings-ui/src/types.ts
index 8b360fc615f..334bad64204 100644
--- a/packages/js/settings-ui/src/types.ts
+++ b/packages/js/settings-ui/src/types.ts
@@ -72,9 +72,29 @@ export type SettingsUIShellNavigationItem = {
active?: boolean;
};
+/**
+ * Visual intent for a shell header badge. Mirrors common web semantic
+ * conventions and maps to the `wc-settings-ui-shell__badge--<intent>`
+ * CSS modifiers. Intent conveys color only — the `label` text must
+ * carry the actual meaning for screen-reader and color-blind users.
+ */
+export type SettingsUIShellBadgeIntent =
+ | 'default'
+ | 'info'
+ | 'success'
+ | 'warning'
+ | 'error';
+
+export type SettingsUIShellBadge = {
+ label: string;
+ intent?: SettingsUIShellBadgeIntent;
+};
+
export type SettingsUIShell = {
title?: string;
+ subtitle?: string;
breadcrumbs?: SettingsUIShellBreadcrumb[];
+ badges?: SettingsUIShellBadge[];
navigation?: SettingsUIShellNavigationItem[];
sectionNavigation?: SettingsUIShellNavigationItem[];
navigationComponent?: string;
diff --git a/plugins/woocommerce/changelog/add-settings-ui-shell-header-fields b/plugins/woocommerce/changelog/add-settings-ui-shell-header-fields
new file mode 100644
index 00000000000..6bf68477fe3
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-settings-ui-shell-header-fields
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add styling for settings UI shell header badges.
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/settings-embed/settings-ui.scss b/plugins/woocommerce/client/admin/client/wp-admin-scripts/settings-embed/settings-ui.scss
index d7b57058491..88d460dc7ec 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/settings-embed/settings-ui.scss
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/settings-embed/settings-ui.scss
@@ -134,6 +134,43 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
}
}
+ .wc-settings-ui-shell__badge {
+ align-items: center;
+ border-radius: 2px;
+ display: inline-flex;
+ font-size: 12px;
+ font-weight: 400;
+ line-height: 16px;
+ min-height: 24px;
+ padding: 0 8px;
+
+ // Default / neutral — also applies to `&--default`, which inherits the base styling.
+ background: var(--Alias-bg-bg-surface-secondary, #f5f5f5);
+ color: var(--Alias-text-text, #070707);
+
+ &--success {
+ background: var(--Alias-bg-bg-surface-success, #daffe0);
+ color: var(--Alias-text-text-success, #144722);
+ }
+
+ &--info {
+ background: var(--Alias-bg-bg-surface-secondary, #f5f5f5);
+ color: var(--Alias-text-text, #070707);
+ }
+
+ &--warning {
+ background: var(--Alias-bg-bg-surface-warning, #fff2d7);
+ color: var(--Alias-text-text-warning, #4d3716);
+ }
+
+ &--error {
+ // No `--Alias-*-error` token is defined in the admin design-token set,
+ // so use a direct color until one ships.
+ background: #fce4e4;
+ color: #5a0000;
+ }
+ }
+
.wc-settings-ui-shell__navigation {
border-bottom: 1px solid #f0f0f0;
margin: 0;