Commit 37cefb28605 for woocommerce

commit 37cefb286058e894f4b0a7e810f35b7c5f667de8
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Tue Aug 18 13:56:50 2026 +0200

    Make WooCommerce Admin header non-sticky (#67379)

    * Make WooCommerce Admin header non-sticky

    * fix lint error

diff --git a/plugins/woocommerce/changelog/wooplug-7407-non-sticky-admin-header b/plugins/woocommerce/changelog/wooplug-7407-non-sticky-admin-header
new file mode 100644
index 00000000000..ad482ab27aa
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7407-non-sticky-admin-header
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Make the WooCommerce Admin header non-sticky on all pages.
diff --git a/plugins/woocommerce/client/admin/client/activity-panel/style.scss b/plugins/woocommerce/client/admin/client/activity-panel/style.scss
index 2d2f07df982..e102e203355 100644
--- a/plugins/woocommerce/client/admin/client/activity-panel/style.scss
+++ b/plugins/woocommerce/client/admin/client/activity-panel/style.scss
@@ -174,7 +174,7 @@
 		outline: none;
 	}

-	height: calc(100vh - #{$header-height + $adminbar-height-mobile});
+	height: calc(100vh - var(--woocommerce-admin-header-height) - #{$adminbar-height-mobile});
 	background: $gray-100;
 	width: 430px;
 	@include breakpoint( "<782px" ) {
@@ -182,22 +182,24 @@
 	}
 	transform: translateX(100%);
 	@include activity-panel-slide();
-	position: absolute;
+	position: fixed;
 	right: 0;
-	// Clear the floating header's bottom border so it stays visible when the
-	// slide-over panel is open (otherwise the panel covers it). Shares the
-	// border width with the header — if that ever changes, this stays aligned.
-	top: calc(100% + #{$header-border-width});
+	top: calc(var(--woocommerce-admin-header-height) + #{$adminbar-height-mobile + $header-border-width});
 	z-index: 1000;
 	overflow-x: hidden;
 	overflow-y: auto;

 	@include breakpoint( ">782px" ) {
-		height: calc(100vh - #{$header-height + $adminbar-height});
+		height: calc(100vh - var(--woocommerce-admin-header-height) - #{$adminbar-height});
+		top: calc(var(--woocommerce-admin-header-height) + #{$adminbar-height + $header-border-width});
+	}
+	.is-wp-toolbar-disabled & {
+		height: calc(100vh - var(--woocommerce-admin-header-height));
+		top: calc(var(--woocommerce-admin-header-height) + #{$header-border-width});
 	}
 	.has-woocommerce-navigation & {
-		height: calc(100vh - #{$header-height});
-		top: #{$header-height};
+		height: calc(100vh - var(--woocommerce-admin-header-height));
+		top: var(--woocommerce-admin-header-height);
 	}

 	&.is-open {
diff --git a/plugins/woocommerce/client/admin/client/header/shared.tsx b/plugins/woocommerce/client/admin/client/header/shared.tsx
index ab000f3253a..1bea55fa156 100644
--- a/plugins/woocommerce/client/admin/client/header/shared.tsx
+++ b/plugins/woocommerce/client/admin/client/header/shared.tsx
@@ -1,66 +1,17 @@
 /**
  * External dependencies
  */
-import { useCallback, useLayoutEffect, useRef } from '@wordpress/element';
 import { useSlot, Text } from '@woocommerce/experimental';
+import type { ReactNode } from 'react';
 import clsx from 'clsx';
 import { decodeEntities } from '@wordpress/html-entities';
 import {
-	WC_HEADER_SLOT_NAME,
 	WC_HEADER_PAGE_TITLE_SLOT_NAME,
 	WooHeaderNavigationItem,
 	WooHeaderItem,
 	WooHeaderPageTitle,
 } from '@woocommerce/admin-layout';

-/**
- * Internal dependencies
- */
-import useIsScrolled from '~/hooks/useIsScrolled';
-
-export const useUpdateBodyMargin = ( {
-	headerElement,
-	headerItemSlot,
-}: {
-	headerElement: React.RefObject< HTMLDivElement >;
-	headerItemSlot: ReturnType< typeof useSlot >;
-} ) => {
-	const debounceTimer = useRef< NodeJS.Timeout | null >( null );
-
-	const updateBodyMargin = useCallback( () => {
-		if ( debounceTimer.current ) {
-			clearTimeout( debounceTimer.current );
-		}
-
-		debounceTimer.current = setTimeout( function () {
-			const wpBody =
-				document.querySelector< HTMLDivElement >( '#wpbody' );
-
-			if ( ! wpBody || ! headerElement.current ) {
-				return;
-			}
-
-			wpBody.style.marginTop = `${ headerElement.current.clientHeight }px`;
-		}, 200 );
-	}, [ headerElement ] );
-
-	useLayoutEffect( () => {
-		updateBodyMargin();
-		window.addEventListener( 'resize', updateBodyMargin );
-		return () => {
-			window.removeEventListener( 'resize', updateBodyMargin );
-			const wpBody =
-				document.querySelector< HTMLDivElement >( '#wpbody' );
-
-			if ( ! wpBody ) {
-				return;
-			}
-
-			wpBody.style.marginTop = '';
-		};
-	}, [ headerItemSlot?.fills, updateBodyMargin ] );
-};
-
 export const getPageTitle = ( sections: string[] ) => {
 	let pageTitle;
 	const pagesWithTabs = [
@@ -83,8 +34,8 @@ export const getPageTitle = ( sections: string[] ) => {

 /**
  * BaseHeader is a dumb layout component shared by Header (non-embedded WC
- * admin pages) and EmbedHeader (overlay on top of classic wp-admin pages).
- * It owns the fixed-position bar, body-margin sync, and slot rendering.
+ * admin pages) and EmbedHeader (header for classic wp-admin pages).
+ * It owns the header bar and slot rendering.
  * Anything wp-admin-specific (h1 suppression,
  * compact-bar mode, Screen Options / Help proxy icons) is the caller's
  * responsibility — passed in via `suppressTitle`, `compact`, and the
@@ -103,7 +54,7 @@ export const BaseHeader = ( {
 	isEmbedded: boolean;
 	query: Record< string, string >;
 	sections: string[];
-	children?: React.ReactNode;
+	children?: ReactNode;
 	leftAlign?: boolean;
 	/**
 	 * When true, render a spacer instead of the title. Caller (EmbedHeader)
@@ -122,31 +73,21 @@ export const BaseHeader = ( {
 	 * uses this for the gear / ? icons that proxy clicks into wp-admin's
 	 * Screen Options and Help dropdowns.
 	 */
-	trailingItems?: React.ReactNode;
+	trailingItems?: ReactNode;
 } ) => {
-	const { isScrolled } = useIsScrolled();
-
-	const headerElement = useRef< HTMLDivElement >( null );
 	const pageTitleSlot = useSlot( WC_HEADER_PAGE_TITLE_SLOT_NAME );
 	const hasPageTitleFills = Boolean( pageTitleSlot?.fills?.length );
-	const headerItemSlot = useSlot( WC_HEADER_SLOT_NAME );
-	useUpdateBodyMargin( {
-		headerElement,
-		headerItemSlot,
-	} );

 	const shouldRenderTitle = hasPageTitleFills || ! suppressTitle;

 	return (
 		<div
 			className={ clsx( 'woocommerce-layout__header', {
-				'is-scrolled': isScrolled,
 				// Chrome-only treatment: bar collapses to admin-bar height when
 				// the caller requests it (e.g. Edit Order, Edit Product, Add
 				// Product, where wp-admin renders its own title below).
 				'is-chrome-only': compact,
 			} ) }
-			ref={ headerElement }
 		>
 			<div className="woocommerce-layout__header-wrapper">
 				<WooHeaderNavigationItem.Slot
diff --git a/plugins/woocommerce/client/admin/client/header/style.scss b/plugins/woocommerce/client/admin/client/header/style.scss
index 20a7e62bcbb..d0c82f7a658 100644
--- a/plugins/woocommerce/client/admin/client/header/style.scss
+++ b/plugins/woocommerce/client/admin/client/header/style.scss
@@ -16,7 +16,7 @@
 }

 // Hide ONLY the two standard wp-admin meta-toggle wraps (Screen Options +
-// Help) that we proxy via the floating header's gear / ? icons. Applied via
+// Help) that we proxy via the header's gear / ? icons. Applied via
 // the server-rendered body class so the hide is immediate — no flash before
 // React mounts. The parent #screen-meta-links container is left alone so
 // third-party .screen-meta-toggle siblings remain visible. Visually-hidden
@@ -30,10 +30,11 @@
 }

 .woocommerce-layout__header {
+	--woocommerce-admin-header-height: #{$header-height};
+
 	// Background matches the wp-admin body grey so the bar reads as
 	// infrastructure rather than content. Needs to be explicit (not inherited)
-	// because `position: fixed` lets page content scroll behind the bar — a
-	// transparent background would show that content through. Bottom border
+	// so the header is consistent across WooCommerce Admin pages. Bottom border
 	// anchors the bar visually against the page below. Settings-page tweaks
 	// (drop the border so the bar reads as one strip with the tab nav)
 	// live in the legacy admin.scss alongside the rest of that page's overrides.
@@ -41,15 +42,9 @@
 	border-bottom: $header-border-width solid $wp-admin-border;
 	box-sizing: border-box;
 	padding: 0;
-	position: fixed;
-	width: calc(100% - 160px);
-	top: $adminbar-height;
+	position: relative;
 	z-index: 1001; /* on top of #wp-content-editor-tools */

-	&.is-scrolled {
-		box-shadow: $header-scroll-shadow;
-	}
-
 	.woocommerce-layout__header-wrapper {
 		display: flex;
 		align-items: center;
@@ -58,12 +53,6 @@

 	@include breakpoint("<782px") {
 		flex-flow: row wrap;
-		top: $adminbar-height-mobile;
-		width: 100%;
-	}
-
-	@include breakpoint("782px-960px") {
-		width: calc(100% - 36px);
 	}

 	.woocommerce-layout__header-breadcrumbs-wrapper {
@@ -191,7 +180,8 @@
 		}
 		&:focus-visible,
 		&.components-button:not(:disabled):not([aria-disabled="true"]):focus-visible {
-			box-shadow: inset -1px -1px 0 $gray-700,
+			box-shadow:
+				inset -1px -1px 0 $gray-700,
 				inset 1px 1px 0 $gray-700;
 		}

@@ -205,6 +195,8 @@
 	// taller by any naturally-sized child (Finish setup widget, plugin fills, etc.).
 	// Inner activity-panel and meta-icon items also collapse so they fit cleanly.
 	&.is-chrome-only {
+		--woocommerce-admin-header-height: #{$adminbar-height};
+
 		.woocommerce-layout__header-wrapper {
 			height: $adminbar-height;
 			min-height: $adminbar-height;
@@ -234,8 +226,12 @@
 // Firefox 121+). Older browsers fall through to the JS-driven class
 // path above and still work — they just get the small flash.
 body:has(.wrap > h1.wp-heading-inline) {
-	#wpbody {
-		margin-top: $adminbar-height;
+	.woocommerce-layout__header {
+		--woocommerce-admin-header-height: #{$adminbar-height};
+
+		#wpbody {
+			margin-top: $adminbar-height;
+		}
 	}

 	.woocommerce-layout__header-heading {
@@ -256,32 +252,9 @@ body:has(.wrap > h1.wp-heading-inline) {
 	}
 }

-.folded .woocommerce-layout__header {
-	width: calc(100% - 36px);
-
-	@include breakpoint("<782px") {
-		width: 100%;
-	}
-}
-
-.is-wp-toolbar-disabled .woocommerce-layout__header {
-	top: 0;
-}
-
-.has-woocommerce-navigation .woocommerce-layout__header {
-	left: 0;
-	width: 100%;
-}
-
 .woocommerce-admin-page {
 	#contextual-help-link-wrap,
 	#screen-options-link-wrap {
 		margin-top: -1px;
 	}
 }
-
-.wp-responsive-open {
-	.woocommerce-layout__header {
-		margin-left: 2px;
-	}
-}
diff --git a/plugins/woocommerce/client/admin/client/header/test/index.test.js b/plugins/woocommerce/client/admin/client/header/test/index.test.js
index 4ca76d76c06..64010416b4c 100644
--- a/plugins/woocommerce/client/admin/client/header/test/index.test.js
+++ b/plugins/woocommerce/client/admin/client/header/test/index.test.js
@@ -1,7 +1,7 @@
 /**
  * External dependencies
  */
-import { render, fireEvent } from '@testing-library/react';
+import { render } from '@testing-library/react';
 import React from 'react';

 /**
@@ -51,19 +51,6 @@ const encodedBreadcrumb = [
 ];

 describe( 'Header', () => {
-	beforeEach( () => {
-		// Mock RAF to be synchronous for testing
-		jest.spyOn( window, 'requestAnimationFrame' ).mockImplementation(
-			( cb ) => {
-				cb();
-			}
-		);
-	} );
-
-	afterEach( () => {
-		window.requestAnimationFrame.mockRestore();
-	} );
-
 	it( 'should render decoded breadcrumb name', () => {
 		const { queryByText } = render(
 			<Header sections={ encodedBreadcrumb } query={ {} } />
@@ -72,25 +59,6 @@ describe( 'Header', () => {
 		expect( queryByText( 'Accounts & Privacy' ) ).not.toBe( null );
 	} );

-	it( 'should only have the is-scrolled class if the page is scrolled', () => {
-		const { container } = render(
-			<Header sections={ encodedBreadcrumb } query={ {} } />
-		);
-
-		const topLevelElement = container.firstChild;
-		expect( Object.values( topLevelElement.classList ) ).not.toContain(
-			'is-scrolled'
-		);
-		Object.defineProperty( window, 'pageYOffset', {
-			value: 200,
-			writable: false,
-		} );
-		fireEvent.scroll( window, { target: { scrollY: 200 } } );
-		expect( Object.values( topLevelElement.classList ) ).toContain(
-			'is-scrolled'
-		);
-	} );
-
 	it( 'correctly updates the document title to reflect the navigation state', () => {
 		render( <Header sections={ encodedBreadcrumb } query={ {} } /> );

diff --git a/plugins/woocommerce/client/admin/client/header/test/shared.test.js b/plugins/woocommerce/client/admin/client/header/test/shared.test.js
index 4bc77af98a9..fa3720b62e8 100644
--- a/plugins/woocommerce/client/admin/client/header/test/shared.test.js
+++ b/plugins/woocommerce/client/admin/client/header/test/shared.test.js
@@ -1,15 +1,14 @@
 /**
  * External dependencies
  */
-import { render, screen, act } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
 import { useSlot } from '@woocommerce/experimental';
 import React from 'react';

 /**
  * Internal dependencies
  */
-import useIsScrolled from '~/hooks/useIsScrolled';
-import { getPageTitle, useUpdateBodyMargin, BaseHeader } from '../shared';
+import { getPageTitle, BaseHeader } from '../shared';

 // Mock dependencies
 jest.mock( '@woocommerce/experimental', () => ( {
@@ -29,11 +28,6 @@ jest.mock( '@woocommerce/experimental', () => ( {
 	},
 } ) );

-jest.mock( '~/hooks/useIsScrolled', () => ( {
-	__esModule: true,
-	default: jest.fn(),
-} ) );
-
 jest.mock( '@wordpress/html-entities', () => ( {
 	decodeEntities: ( content ) => content,
 } ) );
@@ -117,178 +111,9 @@ describe( 'getPageTitle', () => {
 	} );
 } );

-describe( 'useUpdateBodyMargin', () => {
-	beforeEach( () => {
-		// Setup DOM elements needed for the tests
-		document.body.innerHTML = '<div id="wpbody"></div>';
-
-		// Reset mocks
-		jest.useFakeTimers();
-	} );
-
-	afterEach( () => {
-		jest.clearAllMocks();
-		jest.clearAllTimers();
-		document.body.innerHTML = '';
-	} );
-
-	test( 'should update wpbody margin top based on header height', () => {
-		// Create a mock ref with clientHeight
-		const headerElement = { current: { clientHeight: 100 } };
-		const headerItemSlot = { fills: [] };
-
-		// Create a test component to use the hook.
-		// The hook's useLayoutEffect triggers updateBodyMargin on mount.
-		const TestComponent = () => {
-			useUpdateBodyMargin( {
-				headerElement,
-				headerItemSlot,
-			} );
-			return null;
-		};
-
-		render( <TestComponent /> );
-
-		// Fast-forward timers to trigger the debounced function
-		act( () => {
-			jest.advanceTimersByTime( 200 );
-		} );
-
-		const wpBody = document.querySelector( '#wpbody' );
-		expect( wpBody.style.marginTop ).toBe( '100px' );
-	} );
-
-	test( 'should clean up event listeners and reset margin on unmount', () => {
-		// Create a mock ref with clientHeight
-		const headerElement = { current: { clientHeight: 100 } };
-		const headerItemSlot = { fills: [] };
-
-		// Spy on event listeners
-		const addEventListenerSpy = jest.spyOn( window, 'addEventListener' );
-		const removeEventListenerSpy = jest.spyOn(
-			window,
-			'removeEventListener'
-		);
-
-		// Create a test component to use the hook
-		const TestComponent = () => {
-			useUpdateBodyMargin( {
-				headerElement,
-				headerItemSlot,
-			} );
-			return null;
-		};
-
-		const { unmount } = render( <TestComponent /> );
-
-		// Verify event listener was added
-		expect( addEventListenerSpy ).toHaveBeenCalledWith(
-			'resize',
-			expect.any( Function )
-		);
-
-		// Fast-forward timers to trigger the debounced function
-		act( () => {
-			jest.advanceTimersByTime( 200 );
-		} );
-
-		// Verify margin was set
-		const wpBody = document.querySelector( '#wpbody' );
-		expect( wpBody.style.marginTop ).toBe( '100px' );
-
-		// Unmount component
-		unmount();
-
-		// Verify event listener was removed
-		expect( removeEventListenerSpy ).toHaveBeenCalledWith(
-			'resize',
-			expect.any( Function )
-		);
-	} );
-
-	test( 'should handle null wpbody or headerElement', () => {
-		// Remove wpbody from DOM
-		document.body.innerHTML = '';
-
-		// Create a mock ref with null current
-		const headerElement = { current: null };
-		const headerItemSlot = { fills: [] };
-
-		// Create a test component to use the hook.
-		// The hook's useLayoutEffect triggers updateBodyMargin on mount.
-		const TestComponent = () => {
-			useUpdateBodyMargin( {
-				headerElement,
-				headerItemSlot,
-			} );
-			return null;
-		};
-
-		render( <TestComponent /> );
-
-		// Fast-forward timers to trigger the debounced function
-		act( () => {
-			jest.advanceTimersByTime( 200 );
-		} );
-
-		// Should not throw errors
-		expect( true ).toBe( true );
-	} );
-
-	test( 'should debounce multiple rapid resizes', () => {
-		// NOTE: This test couples to the hook's mount-time behavior.
-		// useUpdateBodyMargin's initial measurement runs through the same
-		// 200ms debounce path, so clientHeight is not read until timers
-		// advance. If a future refactor switches to a synchronous mount-time
-		// measurement, the pre-advance assertion below will need updating.
-
-		// Spy on clientHeight reads so we can assert the debounced update
-		// runs exactly once, not once per resize event.
-		const clientHeightGetter = jest.fn( () => 100 );
-		const headerElement = { current: {} };
-		Object.defineProperty( headerElement.current, 'clientHeight', {
-			get: clientHeightGetter,
-		} );
-		const headerItemSlot = { fills: [] };
-
-		const TestComponent = () => {
-			useUpdateBodyMargin( {
-				headerElement,
-				headerItemSlot,
-			} );
-			return null;
-		};
-
-		render( <TestComponent /> );
-
-		// Fire multiple rapid resize events without advancing timers.
-		// Each resize resets the 200ms debounce timer.
-		act( () => {
-			window.dispatchEvent( new Event( 'resize' ) );
-			window.dispatchEvent( new Event( 'resize' ) );
-			window.dispatchEvent( new Event( 'resize' ) );
-		} );
-
-		// Before the debounce window elapses, nothing should have run yet:
-		// margin should not be set and clientHeight should not have been read.
-		const wpBody = document.querySelector( '#wpbody' );
-		expect( wpBody.style.marginTop ).toBe( '' );
-		expect( clientHeightGetter ).not.toHaveBeenCalled();
-
-		// After advancing past the debounce window, margin is applied once
-		// and clientHeight is read exactly once — proving coalescence.
-		act( () => {
-			jest.advanceTimersByTime( 200 );
-		} );
-		expect( wpBody.style.marginTop ).toBe( '100px' );
-		expect( clientHeightGetter ).toHaveBeenCalledTimes( 1 );
-	} );
-} );
-
 describe( 'BaseHeader', () => {
 	beforeEach( () => {
 		// Setup mocks
-		useIsScrolled.mockReturnValue( { isScrolled: false } );
 		useSlot.mockImplementation( ( slotName ) => {
 			if ( slotName === 'wc-header-page-title' ) {
 				return { fills: [] };
@@ -320,25 +145,6 @@ describe( 'BaseHeader', () => {
 		expect( header.textContent ).toBe( 'WooCommerce' );
 	} );

-	test( 'should render with is-scrolled class when isScrolled is true', () => {
-		// Mock isScrolled to return true
-		useIsScrolled.mockReturnValue( { isScrolled: true } );
-
-		const props = {
-			isEmbedded: false,
-			query: {},
-			sections: [ 'WooCommerce' ],
-		};
-
-		render( <BaseHeader { ...props } /> );
-
-		// Check header has is-scrolled class
-		const headerContainer = document.querySelector(
-			'.woocommerce-layout__header'
-		);
-		expect( headerContainer ).toHaveClass( 'is-scrolled' );
-	} );
-
 	test( 'should render with right alignment when leftAlign is false', () => {
 		const props = {
 			isEmbedded: false,
diff --git a/plugins/woocommerce/client/admin/client/stylesheets/_embed.scss b/plugins/woocommerce/client/admin/client/stylesheets/_embed.scss
index 6d32bf9d953..b0fd92bfbc8 100644
--- a/plugins/woocommerce/client/admin/client/stylesheets/_embed.scss
+++ b/plugins/woocommerce/client/admin/client/stylesheets/_embed.scss
@@ -47,10 +47,6 @@
 	}

 	.woocommerce-layout__header {
-		&.is-scrolled {
-			box-shadow: 0 8px 16px 0 rgba(85, 93, 102, 0.3);
-		}
-
 		.woocommerce-layout__header-heading {
 			margin-top: 0;
 			margin-bottom: 0;
diff --git a/plugins/woocommerce/client/admin/client/stylesheets/shared/_reset.scss b/plugins/woocommerce/client/admin/client/stylesheets/shared/_reset.scss
index 7e51eba8ccc..0e0eecdb06f 100644
--- a/plugins/woocommerce/client/admin/client/stylesheets/shared/_reset.scss
+++ b/plugins/woocommerce/client/admin/client/stylesheets/shared/_reset.scss
@@ -50,7 +50,6 @@
 	width: 100%;
 	box-sizing: border-box;
 	padding-top: 0;
-	margin-top: $header-height;
 }

 // Hide wpfooter on customize store pages.