diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9db70934e007..6320b407a48e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -536,6 +536,9 @@ importers:
'@babel/runtime':
specifier: ^7
version: 7.28.6
+ '@wordpress/admin-ui':
+ specifier: 1.8.0
+ version: 1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@wordpress/browserslist-config':
specifier: 6.40.0
version: 6.40.0
diff --git a/projects/js-packages/components/changelog/update-normalize-admin-page-headers b/projects/js-packages/components/changelog/update-normalize-admin-page-headers
new file mode 100644
index 000000000000..ad02bc7be2d0
--- /dev/null
+++ b/projects/js-packages/components/changelog/update-normalize-admin-page-headers
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add AdminHeader component wrapping @wordpress/admin-ui Page for unified admin page headers.
diff --git a/projects/js-packages/components/components/admin-header/admin-ui-styles.css b/projects/js-packages/components/components/admin-header/admin-ui-styles.css
new file mode 100644
index 000000000000..b9b1bf26cef7
--- /dev/null
+++ b/projects/js-packages/components/components/admin-header/admin-ui-styles.css
@@ -0,0 +1,8 @@
+/* Proxy file to import @wordpress/admin-ui styles.
+ *
+ * Importing the CSS via @import inside a .css file avoids the
+ * @wordpress/dependency-extraction-webpack-plugin externalization that
+ * breaks direct JS `import '@wordpress/admin-ui/build-style/style.css'`.
+ * css-loader resolves @import independently of the externals plugin.
+ */
+@import "@wordpress/admin-ui/build-style/style.css";
diff --git a/projects/js-packages/components/components/admin-header/index.tsx b/projects/js-packages/components/components/admin-header/index.tsx
new file mode 100644
index 000000000000..516729064504
--- /dev/null
+++ b/projects/js-packages/components/components/admin-header/index.tsx
@@ -0,0 +1,59 @@
+import { Page } from '@wordpress/admin-ui';
+import './admin-ui-styles.css';
+import {
+ __experimentalHeading as Heading, // eslint-disable-line @wordpress/no-unsafe-wp-apis
+ __experimentalHStack as HStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis
+} from '@wordpress/components';
+import JetpackLogo from '../jetpack-logo/index.tsx';
+import type { AdminHeaderProps } from './types.ts';
+import type { FC } from 'react';
+
+/**
+ * Unified admin page header component.
+ *
+ * Renders a sticky header with logo, product title, optional subtitle,
+ * actions, and tabs. Wraps the `@wordpress/admin-ui` Page component.
+ *
+ * @param {AdminHeaderProps} props - Component properties.
+ * @return {ReactNode} AdminHeader component.
+ */
+const AdminHeader: FC< AdminHeaderProps > = ( {
+ logo,
+ title,
+ subTitle,
+ actions,
+ tabs = null,
+ className,
+ breadcrumbs = null,
+ badges = null,
+} ) => {
+ const classes = className;
+
+ // While admin-ui Page has a title prop, it fails to render both the logo and
+ // text. Internally it tries to accommodate both inside Heading.
+ // Composing here with Heading as it is on admin-ui Page.
+ const composedTitle = title ? (
+
+ { logo || }
+
+ { title }
+
+
+ ) : undefined;
+
+ return (
+
+ { tabs }
+
+ );
+};
+
+export default AdminHeader;
diff --git a/projects/js-packages/components/components/admin-header/types.ts b/projects/js-packages/components/components/admin-header/types.ts
new file mode 100644
index 000000000000..cd2940a4b5b8
--- /dev/null
+++ b/projects/js-packages/components/components/admin-header/types.ts
@@ -0,0 +1,43 @@
+import type { ReactNode } from 'react';
+
+export type AdminHeaderProps = {
+ /**
+ * Custom logo element. Defaults to JetpackLogo icon (bolt only).
+ */
+ logo?: ReactNode;
+
+ /**
+ * Product title displayed next to the logo.
+ */
+ title: string;
+
+ /**
+ * Optional subtitle displayed below the title row.
+ */
+ subTitle?: string;
+
+ /**
+ * Optional breadcrumb elements displayed next to the title.
+ */
+ breadcrumbs?: ReactNode;
+
+ /**
+ * Optional badge elements displayed next to the title.
+ */
+ badges?: ReactNode;
+
+ /**
+ * Optional action elements (buttons, links) displayed on the right side of the header.
+ */
+ actions?: ReactNode;
+
+ /**
+ * Optional tab navigation displayed below the title/tagline.
+ */
+ tabs?: ReactNode;
+
+ /**
+ * Additional CSS class name.
+ */
+ className?: string;
+};
diff --git a/projects/js-packages/components/components/admin-page/index.tsx b/projects/js-packages/components/components/admin-page/index.tsx
index c543af519dc6..d0bad43689b2 100644
--- a/projects/js-packages/components/components/admin-page/index.tsx
+++ b/projects/js-packages/components/components/admin-page/index.tsx
@@ -2,6 +2,7 @@ import restApi from '@automattic/jetpack-api';
import { __, sprintf } from '@wordpress/i18n';
import clsx from 'clsx';
import { useEffect, useCallback } from 'react';
+import AdminHeader from '../admin-header/index.tsx';
import JetpackFooter from '../jetpack-footer/index.tsx';
import JetpackLogo from '../jetpack-logo/index.tsx';
import Col from '../layout/col/index.tsx';
@@ -32,6 +33,11 @@ const AdminPage: FC< AdminPageProps > = ( {
apiNonce = '',
optionalMenuItems,
header,
+ title,
+ subTitle,
+ logo,
+ actions,
+ tabs,
} ) => {
useEffect( () => {
restApi.setApiRoot( apiRoot );
@@ -62,25 +68,35 @@ const AdminPage: FC< AdminPageProps > = ( {
return (
- { showHeader && (
-
-
- { header ? header : }
- { sandboxedDomain && (
-
- API Sandboxed
-
- ) }
-
-
+ { showHeader && title ? (
+
+ ) : (
+ showHeader && (
+
+
+ { header ? header : }
+ { sandboxedDomain && (
+
+ API Sandboxed
+
+ ) }
+
+
+ )
) }
{ children }
diff --git a/projects/js-packages/components/components/admin-page/types.ts b/projects/js-packages/components/components/admin-page/types.ts
index 7e9e6ad59bf3..bec390ae496d 100644
--- a/projects/js-packages/components/components/admin-page/types.ts
+++ b/projects/js-packages/components/components/admin-page/types.ts
@@ -18,10 +18,37 @@ export type AdminPageProps = {
showHeader?: boolean;
/**
- * Custom header. Optional
+ * Custom header. Optional.
+ * @deprecated Use `title` and `subTitle` props instead for the unified header.
*/
header?: ReactNode;
+ /**
+ * Product title displayed in the unified header (e.g. "Social", "Backup").
+ * When provided, renders the new AdminHeader instead of the legacy header slot.
+ */
+ title?: string;
+
+ /**
+ * Optional tagline displayed below the title in the unified header.
+ */
+ subTitle?: string;
+
+ /**
+ * Custom logo element for the unified header. Defaults to JetpackLogo icon.
+ */
+ logo?: ReactNode;
+
+ /**
+ * Action elements displayed on the right side of the unified header.
+ */
+ actions?: ReactNode;
+
+ /**
+ * Tab navigation displayed below the title/tagline in the unified header.
+ */
+ tabs?: ReactNode;
+
/**
* Whether or not to display the Footer
*/
diff --git a/projects/js-packages/components/index.ts b/projects/js-packages/components/index.ts
index 5b2df114305e..7dc51e617bbc 100644
--- a/projects/js-packages/components/index.ts
+++ b/projects/js-packages/components/index.ts
@@ -34,6 +34,8 @@ export { default as NumberSlider } from './components/number-slider/index.tsx';
export { default as AdminSection } from './components/admin-section/basic/index.tsx';
export { default as AdminSectionHero } from './components/admin-section/hero/index.tsx';
export { default as AdminPage } from './components/admin-page/index.tsx';
+export { default as AdminHeader } from './components/admin-header/index.tsx';
+export type { AdminHeaderProps } from './components/admin-header/types.ts';
export { default as DecorativeCard } from './components/decorative-card/index.tsx';
export { default as Col } from './components/layout/col/index.tsx';
export { default as Testimonials } from './components/testimonials/index.tsx';
diff --git a/projects/js-packages/components/package.json b/projects/js-packages/components/package.json
index a349d5d31e40..1713f5b0eeb3 100644
--- a/projects/js-packages/components/package.json
+++ b/projects/js-packages/components/package.json
@@ -53,6 +53,7 @@
"@automattic/jetpack-script-data": "workspace:*",
"@automattic/number-formatters": "workspace:*",
"@babel/runtime": "^7",
+ "@wordpress/admin-ui": "1.8.0",
"@wordpress/browserslist-config": "6.40.0",
"@wordpress/components": "32.2.0",
"@wordpress/compose": "7.40.0",
diff --git a/projects/js-packages/components/tools/copy-scss-to-build.mjs b/projects/js-packages/components/tools/copy-scss-to-build.mjs
index f2622ba37f5b..505e73b64612 100644
--- a/projects/js-packages/components/tools/copy-scss-to-build.mjs
+++ b/projects/js-packages/components/tools/copy-scss-to-build.mjs
@@ -50,7 +50,7 @@ async function main() {
return;
}
- for await ( const filePath of fs.glob( '**/*.scss', {
+ for await ( const filePath of fs.glob( '**/*.{scss,css}', {
cwd: packageRoot,
exclude: Array.from( IGNORED_DIRS, dir => `**/${ dir }/**` ),
} ) ) {
diff --git a/projects/packages/publicize/_inc/components/admin-page/index.tsx b/projects/packages/publicize/_inc/components/admin-page/index.tsx
index c10b78feb15b..b0ef6e2d3ca1 100644
--- a/projects/packages/publicize/_inc/components/admin-page/index.tsx
+++ b/projects/packages/publicize/_inc/components/admin-page/index.tsx
@@ -8,6 +8,7 @@ import {
} from '@automattic/jetpack-components';
import { useConnection } from '@automattic/jetpack-connection';
import {
+ getMyJetpackUrl,
isJetpackSelfHostedSite,
isSimpleSite,
siteHasFeature,
@@ -15,13 +16,13 @@ import {
} from '@automattic/jetpack-script-data';
import { shouldUseInternalLinks } from '@automattic/jetpack-shared-extension-utils';
import { useSelect } from '@wordpress/data';
-import { useState, useCallback } from '@wordpress/element';
+import { createInterpolateElement, useState, useCallback } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
import { store as socialStore } from '../../social-store';
import { features, getSocialScriptData, hasSocialPaidFeatures } from '../../utils';
import ConnectionScreen from './connection-screen';
import Header from './header';
import InfoSection from './info-section';
-import AdminPageHeader from './page-header';
import './styles.module.scss';
import PricingPage from './pricing-page';
import SupportSection from './support-section';
@@ -65,7 +66,8 @@ export const SocialAdminPage = () => {
return (
@@ -78,10 +80,25 @@ export const SocialAdminPage = () => {
);
}
+ const licenseAction =
+ ! hasSocialPaidFeatures() && isJetpackSite
+ ? createInterpolateElement(
+ __(
+ 'Already have an existing plan or license key? Click here to get started ',
+ 'jetpack-publicize-pkg'
+ ),
+ {
+ a: ,
+ }
+ )
+ : null;
+
return (
}
+ title={ __( 'Social', 'jetpack-publicize-pkg' ) }
+ subTitle={ __( 'Publish once. Share everywhere.', 'jetpack-publicize-pkg' ) }
+ actions={ licenseAction }
showFooter={ isJetpackSite }
useInternalLinks={ shouldUseInternalLinks() }
>
diff --git a/projects/packages/publicize/_inc/components/admin-page/page-header/index.jsx b/projects/packages/publicize/_inc/components/admin-page/page-header/index.jsx
deleted file mode 100644
index bbeb0c2ae4f2..000000000000
--- a/projects/packages/publicize/_inc/components/admin-page/page-header/index.jsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { getMyJetpackUrl, isJetpackSelfHostedSite } from '@automattic/jetpack-script-data';
-import { createInterpolateElement } from '@wordpress/element';
-import { __ } from '@wordpress/i18n';
-import { hasSocialPaidFeatures } from '../../../utils';
-import Logo from './logo';
-import styles from './styles.module.scss';
-
-const AdminPageHeader = () => {
- const isJetpackSite = isJetpackSelfHostedSite();
-
- return (
-
-
-
-
-
- { ! hasSocialPaidFeatures() && isJetpackSite && (
-
- { createInterpolateElement(
- __(
- 'Already have an existing plan or license key? Click here to get started ',
- 'jetpack-publicize-pkg'
- ),
- {
- a: ,
- }
- ) }
-
- ) }
-
- );
-};
-
-export default AdminPageHeader;
diff --git a/projects/packages/publicize/_inc/components/admin-page/page-header/logo.js b/projects/packages/publicize/_inc/components/admin-page/page-header/logo.js
deleted file mode 100644
index fa319c65f09a..000000000000
--- a/projects/packages/publicize/_inc/components/admin-page/page-header/logo.js
+++ /dev/null
@@ -1,22 +0,0 @@
-const Logo = ( { height = 40 } ) => (
-
-
-
-
-
-
-);
-
-export default Logo;
diff --git a/projects/packages/publicize/_inc/components/admin-page/page-header/styles.module.scss b/projects/packages/publicize/_inc/components/admin-page/page-header/styles.module.scss
deleted file mode 100644
index 05a64bb759a4..000000000000
--- a/projects/packages/publicize/_inc/components/admin-page/page-header/styles.module.scss
+++ /dev/null
@@ -1,10 +0,0 @@
-.header {
- display: flex;
- justify-content: space-between;
- flex-wrap: wrap;
- gap: calc(var(--horizontal-spacing) * 3);
-
- .logo {
- flex-shrink: 0;
- }
-}
diff --git a/projects/packages/publicize/_inc/components/admin-page/test/page-header.test.jsx b/projects/packages/publicize/_inc/components/admin-page/test/page-header.test.jsx
deleted file mode 100644
index f74f6e861369..000000000000
--- a/projects/packages/publicize/_inc/components/admin-page/test/page-header.test.jsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import { clearMockedScriptData, mockScriptData } from '../../../utils/test-utils';
-import AdminPageHeader from '../page-header';
-
-describe( 'AdminPageHeader', () => {
- beforeEach( () => {
- mockScriptData();
- } );
-
- afterEach( () => {
- clearMockedScriptData();
- } );
- it( 'should show license text when no paid features and is Jetpack site', () => {
- render( );
- expect(
- screen.getByText( /Already have an existing plan or license key\?/i )
- ).toBeInTheDocument();
- expect( screen.getByRole( 'link' ) ).toHaveAttribute(
- 'href',
- expect.stringContaining( 'admin.php?page=my-jetpack#/add-license' )
- );
- } );
-
- it( 'should not show license text when has paid features', () => {
- mockScriptData( {
- site: {
- plan: {
- features: {
- active: [ 'social-enhanced-publishing' ],
- },
- },
- },
- } );
- render( );
- expect(
- screen.queryByText( /Already have an existing plan or license key\?/i )
- ).not.toBeInTheDocument();
- clearMockedScriptData();
- } );
-
- it( 'should not show license text when not a Jetpack site', () => {
- mockScriptData( {
- site: {
- host: 'wpcom',
- plan: {
- features: {
- active: [ 'social-enhanced-publishing' ],
- },
- },
- },
- } );
- render( );
- expect(
- screen.queryByText( /Already have an existing plan or license key\?/i )
- ).not.toBeInTheDocument();
- clearMockedScriptData();
- } );
-} );
diff --git a/projects/packages/publicize/changelog/update-normalize-admin-page-headers b/projects/packages/publicize/changelog/update-normalize-admin-page-headers
new file mode 100644
index 000000000000..179dd8abc914
--- /dev/null
+++ b/projects/packages/publicize/changelog/update-normalize-admin-page-headers
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Migrate admin page header to use unified AdminHeader component.