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.