Commit 4fe493f8bcc for woocommerce
commit 4fe493f8bcc2821cc729111020706395faca9c41
Author: Jill Q. <jill.quek@automattic.com>
Date: Thu May 14 12:13:16 2026 +0800
Floating header: suppress duplicate H1, unify chrome to icon tabs (#64643)
* Floating header: prototype consolidated chrome bar
Suppress the floating-header <h1> when wp-admin already renders one
(detected via .wrap > h1.wp-heading-inline at mount). Pages that
register a WooHeaderPageTitle fill or have no wp-admin h1 (Settings,
Home, Analytics) keep their floating title.
Add a chrome-only narrow treatment (~32px) for embedded post-type
screens so the bar reads as infrastructure rather than content. Pages
with a title stay at the original 60px.
Move Screen Options and Help into the floating header as icon buttons
(cog, help). Hide wp-admin's original strip via the visually-hidden
pattern (not display:none, so wp-admin's jQuery click handlers stay
alive). The new icons proxy clicks to #show-settings-link and
#contextual-help-link.
Wire all four right-side icons (Activity, Finish setup, Screen Options,
Help) into a single tab group with mutually-exclusive open state and
the standard blue-underline active treatment. Activity-panel-tab and
header-meta-icon styles are unified.
Replace activity-panel icons with @wordpress/icons equivalents:
IconFlag → bell, FeedbackIcon → megaphone, SetupProgress → listView.
Hide tab labels (still in DOM for screen readers) so the bar is
icon-only across all heights. Match wp-admin omnibar visual weight
with 18px icons, page-grey background, and a 1px bottom border.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: unify icon sizing, page-grey bg, tab-group polish
Bring all five right-side icons (bell / list / megaphone / gear / ?) to
a uniform 18px size — the activity-panel had a stray svg{width:24px}
rule that was beating the size prop on the @wordpress/icons SVGs.
Visual treatment: bar background to wp-admin page grey (#f0f0f1) with
a 1px #dcdcde bottom border so it reads as infrastructure rather than
content. Buttons are transparent (inherit bar bg). Unread-dot ring
recoloured from white to the new bar grey so it blends instead of
showing as a white pill on grey.
Tab-group behaviour:
- Replace showTooltip Button prop with native HTML title attribute on
both header-meta-icon and activity-panel-tab Buttons. Avoids the
Tooltip wrapper component subtly interfering with the cross-system
click-sync.
- Replace mid-click setActiveMetaIcon side-effect with a passive
MutationObserver on aria-expanded of #show-settings-link and
#contextual-help-link. State now updates after the click has fully
settled instead of mid-flight.
- Defer the wp-admin dropdown close from the activity-panel-tab click
listener to a setTimeout(0) macrotask.
- triggerMetaIcon also closes the OTHER wp-admin dropdown for proper
gear ↔ help mutual exclusion.
panel.js: exempt clicks on a sibling activity-panel-tab from the
useFocusOutside close handler. Without this exemption, clicking a
sibling tab while the panel was open raced closePanel against the new
tab's togglePanel — close won, breaking bell ↔ finish-setup switching.
Activity-panel hover: explicit color: $gray-900 to override Button's
default theme-color hover, so all five icons hover to the same darker
grey instead of bell+list turning blue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: Settings page polish + gear↔help switch fix
Settings page renders Woo's tab nav directly below the floating header
with no other chrome between them — treat the two strips as one
continuous title region. Drop the floating header's bottom border and
match the tab strip's white background to the page-grey we use
elsewhere. Keep the tab strip's bottom border (between tabs and
content) but recolour from legacy #EBEBEB to #dcdcde so it matches
all the other borders in the chrome region.
Defer the second click in triggerMetaIcon by 250ms when switching
between gear and help. wp-admin's screen-meta.js uses jQuery
slideUp/slideDown for the dropdown panels (~150ms each). Without the
defer, the slideDown on the new panel races the slideUp on the
closing one and the new panel never opens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: visual polish round 3
Tab indicator: 3px → 2px to match Gutenberg's TabPanel and Woo's own
nav-tab-active indicator on Settings.
Unread dot positioning: anchor to icon centre via calc(50% - 9px) /
calc(50% + 2px) so the dot sits at the top-right of the 18px icon
regardless of bar height (60px with title vs 32px chrome-only). Drops
the responsive breakpoint overrides that assumed wider buttons.
Icon spacing: tighten to wp-admin admin-bar style. Activity-panel-tab
loses width:100% in favour of width:auto + padding:0 8px;
header-meta-icon drops min-width:50px in favour of min-width:0 +
padding:0 8px. Both subsystems now occupy ~34px (8 + 18 + 8) per icon.
Focus state: split :focus into separate :focus / :focus-visible rules
on activity-panel-tab and header-meta-icon. Mouse-click focus shows
nothing extra (just the active blue underline); keyboard focus still
shows the inset shadow ring for a11y.
Slide-over panel: clear the floating header's 1px bottom border by
nudging .woocommerce-layout__activity-panel-wrapper down with
top: calc(100% + 1px). Suppress the browser's default focus outline
on the wrapper (showed as a blue ring with rounded corners on click).
Settings page: tab nav border-bottom recoloured from legacy #EBEBEB
to #dcdcde so it matches the rest of the chrome-region borders.
Activity-panel hover: explicit color: $gray-900 on activity-panel-tab
(was inheriting Button's default theme-color blue, mismatching the
header-meta-icon hover treatment).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: unify Home page tabs + Gutenberg-style hover
Remove the homescreen-specific tab styling override that gave Home
page tabs (View store, display options, account avatar, help) blue
text and bigger padding. They now inherit the same icon-only,
$gray-900, 18px, 0 8px treatment as every other floating-header tab,
so the Home header reads as visually identical to Edit Order, Settings,
etc. — even though the underlying tab componentry differs.
Single colour across all states. Default / hover / active all sit at
$gray-900 — the blue ::before underline alone signals active, mirroring
Gutenberg's icon tab group pattern. Explicit override on hover is
needed to suppress Button's default theme-blue hover.
Bring back showTooltip on Buttons (header-meta-icon and
activity-panel-tab) for the Gutenberg-style instant-fade tooltip
treatment instead of the slow native HTML title attribute. The earlier
mutual-exclusion bugs blamed on the Tooltip wrapper turned out to be
the panel.js useFocusOutside race condition (now fixed) plus
mid-click setActiveMetaIcon (now passive via MutationObserver), so
Tooltip can come back without breaking click sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: hide only standard SO + Help, leave the strip extensible
First cut hid the entire #screen-meta-links strip via height: 0;
overflow: hidden. Popular-plugin sample (PayPal, Stripe, Mollie,
AutomateWoo, Subscriptions, Bookings) showed zero plugins injecting
sibling buttons into the strip — but hosts and power-user installs are
exactly where weird plugin combinations show up, so trade a small
diff for defensive headroom.
Now hide only the two specific standard wraps we replace
(#screen-options-link-wrap, #contextual-help-link-wrap) via the
visually-hidden pattern, leaving the parent #screen-meta-links and
any third-party sibling buttons (rare .screen-meta-toggle injections)
visible. wp-admin's jQuery delegated click handlers still fire on the
hidden wraps, so our gear / ? icons keep working.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: sentence-case "Screen options" tooltip
Match the sentence-case convention used on the other tooltips
(Activity, Feedback, Finish setup, Help) for visual consistency.
wp-admin core uses Title Case for the dropdown panel itself; the
floating-header tooltip is a Woo surface where sentence case applies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add changefile(s) from automation for the following project(s): woocommerce, woocommerce/client/admin
* Floating header: address review feedback
Replace the magic-number setTimeout(250) in triggerMetaIcon with a
one-shot MutationObserver that chains the new dropdown's open off the
closing trigger's aria-expanded flip. wp-admin's screen-meta.js sets
aria-expanded synchronously when its handler fires, so the observer
fires the moment the close has registered — regardless of how long
jQuery's slideUp animation actually takes. Self-disconnects on first
flip so back-to-back gear ↔ help clicks don't accumulate observers.
Drop the setTimeout(0) deferral inside the document-level click
listener too — that listener doesn't update React state any more
(state syncs reactively via the MutationObserver above), so there's
no longer any mid-click setState to defer past.
Add `query` to the detection useEffect's dep array so the wp-admin
H1 / Screen Options / Help wrap detection re-runs on client-side
route transitions. Defensive against BaseHeader staying mounted
across Home → Settings → Customers if WC Admin's layout reuses it.
Wrap the #screen-options-link-wrap / #contextual-help-link-wrap hide
in a `:has(.woocommerce-layout__header-meta-icon)` guard so the hide
only fires when the floating-header replacement icon actually
rendered. If detection misses for any reason — or on browsers
without :has() support (Firefox <121) — the user keeps access to the
original wp-admin strip rather than losing it entirely.
Extract repeated wp-admin Core greys (#f0f0f1 page background,
#dcdcde border) to local SCSS variables in header/style.scss and
activity-panel/style.scss. Not exposed as CSS custom properties on
:root (verified via wp-token-check), so hardcoding is the right call,
but a single source per file beats grepping for the next person.
Refresh stale comments in shared.tsx (icons no longer "non-functional
placeholders") and activity-panel/style.scss ($gray-700 → $gray-900
in the homescreen-override-removed comment).
Append trailing newline to the auto-generated changelog file
(automation omits it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add changefile(s) from automation for the following project(s): woocommerce, woocommerce/client/admin
* Floating header: drop empty SCSS comment line
Stylelint's scss/comment-no-empty rule disallows bare `//` lines.
The empty comment was being used as a paragraph separator between
two adjacent comment blocks; collapse them instead.
* Floating header: address CodeRabbit review feedback
Add aria-expanded={ activeMetaIcon === 'screen-options' } /
aria-expanded={ activeMetaIcon === 'help' } to the gear / ? Buttons.
Assistive tech now sees the toggled state programmatically, matching
the wp-admin pattern users already encounter on the original
#show-settings-link / #contextual-help-link triggers we proxy.
Extract the duplicated visually-hidden CSS pattern to a single SCSS
mixin (`@mixin visually-hidden`) at the top of header/style.scss.
Both call sites — `.woocommerce-layout__activity-panel-tab-title`
and the `#screen-options-link-wrap` / `#contextual-help-link-wrap`
hide — replaced with `@include visually-hidden`. Single source of
truth instead of an 11-property block duplicated twice.
* Floating header: feedback icon megaphone → comment
Megaphone read as outbound announcement. Speech-bubble (comment)
matches the "give feedback" intent better — feedback is a
conversation, not a broadcast. Both from @wordpress/icons; trivial
swap.
* Floating header: address CodeRabbit round 2 feedback
Lazy-initialise the wp-admin DOM detection state (hasWpAdminH1,
hasScreenOptions, hasContextualHelp) by reading the DOM synchronously
in the useState initialiser instead of starting at `false` and updating
in the useEffect commit. Without this, the first render of an embedded
post-type screen (Edit Order, Edit Product, Add Product) had
hasWpAdminH1 = false → shouldRenderTitle = true → floating <h1>
rendered for one frame before the effect committed and re-rendered
without it. That's exactly the duplicate-title flash this PR is meant
to remove. wp-admin's <h1> and meta-link wraps are server-rendered
before React hydrates, so the synchronous read is safe; the useEffect
still runs on `query` changes for client-side route transitions.
Scope decodeEntities to the string branch only. Previously the call
wrapped the entire ternary, including the WooHeaderPageTitle.Slot JSX
element when a fill was registered. decodeEntities is a string
transform — passing JSX is a no-op at runtime but semantically wrong
and confusing on review. Apply it only to the getPageTitle( sections )
string branch where it's actually needed.
(Skipped the regex → endsWith nitpick on activity-panel.js:223 — that
line is pre-existing code outside the diff for this PR.)
* Floating header: initialize meta-icon state on effect mount
Add setActiveMetaIcon(null) to the early return and call sync()
immediately after binding the MutationObserver so activeMetaIcon
reflects current aria-expanded values on mount and after route
transitions, not just on future mutations. Addresses CodeRabbit
round 3 feedback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: fix lint errors + brittle Orders e2e locator
- activity-panel.js: drop unused setupTasksCount / setupTasksCompleteCount
destructured fields (leftover from icon refactor)
- shared.tsx: replace nested ternary in MutationObserver sync with if/else;
prettier formatting from eslint --fix
- order-edit.spec.ts: locator was h1.components-text only, asserting on
the floating Woo h1 that this PR now suppresses on the Orders page in
favor of wp-admin's own h1.wp-heading-inline. Match either source.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: fix panel switching by removing isPanelClosing race-loss
The previous attempt to fix bell↔finish-setup panel switching exempted all
.woocommerce-layout__activity-panel-tab clicks from the focus-outside close.
That over-exempted: tabs like "Preview store" / "Preview site" (custom
onClick that opens a new window, never calls togglePanel) kept the panel
visibly open instead of closing.
Root cause was the isPanelClosing guard in togglePanel(), which bailed when
useFocusOutside fired closePanel() mid-click. Removing the guard (and
clearing isPanelClosing in togglePanel so the flag can't stay stuck-true)
fixes panel switching for every kind of tab without an over-broad exemption.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: address CodeRabbit + chihsuan review feedback
- shared.tsx: unify the three wp-admin chrome detection states into a
single detectWpAdminChrome() reading them together (they always change
together), and drop the typeof document !== 'undefined' SSR guards —
WC Admin is a webpack bundle loaded into wp-admin browser pages, there
is no SSR boundary.
- tab/index.js + header/style.scss: drop the visually-hidden
.woocommerce-layout__activity-panel-tab-title span. The Button already
has aria-label, which screen readers announce in preference to any
descendant text, so the span was redundant.
- activity-panel/style.scss: drop the inline-comment paragraph about
homescreen styling removal (the rationale belongs in PR review, not
the file), and revert the .activity-panel-toggle-bubble border colour
— it styles dead-code ActivityPanelToggleBubble that is never
rendered, so the change was inert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: use @wordpress/icons bellUnread for unread state
The previous approach overlaid a CSS ::after red dot on top of the plain
bell icon, with hand-tuned positioning (`top: calc(50% - 9px); left: calc(50% + 2px)`)
and a 2px ring matching the bar background.
@wordpress/icons already ships `bellUnread` — the same bell with the dot
baked into the SVG at the design-team-blessed coordinates. Swap to it
when there is unread activity, drop the ::after rule and its hover override
entirely. Keep one small `circle { fill: $alert-red }` so the dot reads
as the urgency signal it always was; without that override the dot would
inherit currentColor (gray-900) like the rest of the icon.
Net: less custom CSS, no box-model math, and one source of truth for
unread-state styling lives inside the design system instead of bolted
on the side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: move embed-only logic from shared.tsx into embed.tsx
Address chihsuan's review feedback that wp-admin chrome handling (h1
suppression, Screen Options / Help proxy icons, chrome-only narrow bar)
only ever applies on classic admin pages, so the orchestration belongs in
EmbedHeader rather than the BaseHeader shared between embed and React
routes.
- New `use-wp-admin-chrome.ts` custom hook owns the DOM detection
(`hasH1`, `hasScreenOptions`, `hasContextualHelp`), the
`activeMetaIcon` state synced via MutationObserver, the
cross-system click handler that closes wp-admin dropdowns when an
activity-panel tab is clicked, and the `triggerMetaIcon` function
with its one-shot observer chain for gear ↔ help mutual exclusion.
- `BaseHeader` shrinks to a dumb layout component. New props
`suppressTitle`, `compact`, and `trailingItems` let the caller drive
embed-specific behavior; non-embed `Header` just doesn't pass them and
gets the default (always render title, full height, no trailing items).
- `EmbedHeader` calls the hook, composes the gear / ? icons into
`trailingItems`, and passes `suppressTitle` / `compact` based on
whether wp-admin rendered its own h1.
Behavior is unchanged across Home, Settings, Edit Order, Edit Product,
Add Product, Products list. The seam between embed and non-embed is now
explicit in the prop surface rather than a runtime `isEmbedded` branch
inside the shared component.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: fix CI lint + unit tests after refactor
- activity-panel.js: remove the now-dead isPanelClosing state. The
togglePanel() guard that read its value was removed in the previous
bug fix, so the state was assigned but never read — eslint flagged it
as unused. Drop both the state and the three setIsPanelClosing()
calls (the close/clear lifecycle works fine without the flag).
- header/shared.tsx, header/embed.tsx: apply prettier formatting that
eslint --fix wanted (single-line clsx() arguments and imports).
- activity-panel/test/index.js: swap getByText/queryByText for
getByRole('tab', { name: ... }) on the tab assertions, since we
removed the visually-hidden title <span> in the previous commit.
Tab buttons still expose the same accessible name via aria-label,
so the role+name query lands on the same elements that the text
query used to find.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: fix the second jest test that referenced the title span
The previous test fix only covered client/activity-panel/test/index.js;
client/activity-panel/tab/test/index.js had a separate `getByText('Hello
World')` assertion against the title text that the visually-hidden span
used to render. Tab buttons still expose the same title as their
accessible name via aria-label, so swap the text query for
`getByRole('tab', { name: 'Hello World' })`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: address chihsuan's SCSS dedup feedback
Apply most of round 2's polish notes:
- internal-style-build/abstracts/_variables.scss: update outdated
$wp-admin-background (#f1f1f1 → #f0f0f1, matching modern wp-admin),
add $wp-admin-border (#dcdcde) and $header-border-width (1px) so
multiple files can share these without duplicating literals.
- header/style.scss: drop the local $wp-admin-page-bg / $wp-admin-border
declarations (now in _variables), use $header-border-width on the
bottom border, and move Settings-page-specific overrides out to the
legacy admin.scss where the rest of that page's styling lives.
- activity-panel/style.scss: drop the duplicated local $wp-admin-page-bg
(unused after the bellUnread swap), use the shared $header-border-width
in the slide-over panel's top offset so it stays aligned with the
header's bottom border if that ever changes.
- legacy admin.scss (body.woocommerce_page_wc-settings): add the floating
header's "no bottom border" tweak here so it sits alongside the rest
of the Settings overrides; drop the .nav-tab-wrapper white background
so the strip reads continuously with the floating header bg above it;
recolour the strip's bottom border to #dcdcde to match the rest of
the chrome borders.
Floating header background stays explicit — it's `position: fixed` so
page content scrolls underneath, and a transparent bar would show that
content through.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Floating header: fix same-tab close pop-back by lifting click intent
@chihsuan caught a regression from the earlier isPanelClosing removal:
clicking the active tab to close the panel closes for a frame and pops
back open. Trace:
1. Click → Panel blurs → useFocusOutside calls closePanel() → isPanelOpen=false
2. Tabs mirrors that prop into local tabIsOpenState=false
3. Tabs click handler computes intent from its mirror: same-tab → !false = true
4. togglePanel(tab, true) → setIsPanelOpen(true) → panel pops back open
The old isPanelClosing guard happened to protect against this; removing
it for tab-switching exposed the same-tab close case.
Apply chihsuan's suggested structural fix instead of a narrow guard:
move click-intent decision (open / close / switch) from Tabs into
togglePanel, so it reads from the parent's own state instead of Tabs'
mirrored copy of it.
- Tabs: drop the local useState/useEffect mirror. Becomes a controlled
component — selection state is driven entirely by parent props. Click
forwards the clicked tab via `onTabClick( tab )` (no second arg).
Analytics event moves out (intent isn't known here anymore).
- activity-panel.js togglePanel: handles side-effect tabs (Preview store,
Feedback CES) up front, then derives isSameTab / isClosing / isSwitching
from currentTab + isPanelOpen, records the analytics event for opens
and switches, and sets all four pieces of state at once. The
`isPanelClosing && tabName === currentTab` guard is the narrow piece
that blocks the same-tab re-click during a pending close.
- isPanelClosing is restored (state + closePanel setter + togglePanel
setter) — it's a meaningful flag again, not dead.
- Tests: rewrite tabs/test/index.js to use a small ControlledTabs wrapper
that mirrors the parent's open/close decision, since selection class
state lives in the parent now. Drop the "records an event" test —
Tabs no longer records.
Verified: same-tab close consistently closes (including rapid
double-clicks); switching between Activity / Finish setup still works;
side-effect tabs (Preview store, Preview site) still close the panel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/packages/js/internal-style-build/abstracts/_variables.scss b/packages/js/internal-style-build/abstracts/_variables.scss
index da75f25635e..6eb3c5ff840 100644
--- a/packages/js/internal-style-build/abstracts/_variables.scss
+++ b/packages/js/internal-style-build/abstracts/_variables.scss
@@ -24,6 +24,7 @@ $gap-smallest: 4px;
// Header
$header-height: 60px;
+$header-border-width: 1px;
$header-scroll-shadow: 0 8px 8px 0 rgba(85, 93, 102, 0.3);
// Sidebar
@@ -45,8 +46,12 @@ $adminbar-height-mobile: 46px;
$admin-menu-width: 160px;
$admin-menu-width-collapsed: 36px;
-// wp-admin colors
-$wp-admin-background: #f1f1f1;
+// wp-admin colors. Hardcoded because WordPress doesn't expose them as CSS
+// custom properties on `:root` (verified via the `wp-token-check` skill).
+// Centralised here so the next person doesn't have to grep when WP shifts
+// the bar / border palette.
+$wp-admin-background: #f0f0f1; // wp-admin body background grey
+$wp-admin-border: #dcdcde; // wp-admin chrome border colour
$wp-admin-sidebar: #24292d;
// Muriel
diff --git a/plugins/woocommerce/changelog/64643-sprinkle-embedded-header-no-duplicate-title b/plugins/woocommerce/changelog/64643-sprinkle-embedded-header-no-duplicate-title
new file mode 100644
index 00000000000..898a971a96b
--- /dev/null
+++ b/plugins/woocommerce/changelog/64643-sprinkle-embedded-header-no-duplicate-title
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Refresh the floating WC Admin header to consolidate Screen options, Help, Activity, and Finish setup into a single icon-only tab group, removing the duplicate page title that previously appeared on top of wp-admin's own H1 on classic admin screens.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/admin/client/activity-panel/activity-panel.js b/plugins/woocommerce/client/admin/client/activity-panel/activity-panel.js
index d5c602a323f..4996024ccda 100644
--- a/plugins/woocommerce/client/admin/client/activity-panel/activity-panel.js
+++ b/plugins/woocommerce/client/admin/client/activity-panel/activity-panel.js
@@ -5,7 +5,15 @@ import { __ } from '@wordpress/i18n';
import { lazy, useState, useEffect, useCallback } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import { uniqueId, find } from 'lodash';
-import { Icon, help as helpIcon, external } from '@wordpress/icons';
+import {
+ Icon,
+ help as helpIcon,
+ external,
+ bell,
+ bellUnread,
+ listView,
+ comment,
+} from '@wordpress/icons';
import { STORE_KEY as CES_STORE_KEY } from '@woocommerce/customer-effort-score';
import { H, Section } from '@woocommerce/components';
import { onboardingStore, optionsStore, useUser } from '@woocommerce/data';
@@ -21,10 +29,8 @@ import {
* Internal dependencies
*/
import './style.scss';
-import { IconFlag } from './icon-flag';
import { hasUnreadNotes as checkIfHasUnreadNotes } from './unread-indicators';
import { Tabs } from './tabs';
-import { SetupProgress } from './setup-progress';
import { DisplayOptions } from './display-options';
import { Panel } from './panel';
import {
@@ -37,7 +43,6 @@ import { ABBREVIATED_NOTIFICATION_SLOT_NAME } from './panels/inbox/abbreviated-n
import { getAdminSetting } from '~/utils/admin-settings';
import { getUrlParams } from '~/utils';
import { getSegmentsFromPath } from '~/utils/url-helpers';
-import { FeedbackIcon } from '~/products/images/feedback-icon';
import { useLaunchYourStore } from '~/launch-your-store';
import { useTaskListsState } from '~/hooks/use-tasklists-state';
import HeaderAccount from '../marketplace/components/header-account/header-account';
@@ -150,8 +155,6 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {
requestingTaskListOptions,
setupTaskListComplete,
setupTaskListHidden,
- setupTasksCount,
- setupTasksCompleteCount,
thingsToDoNextCount,
} = useTaskListsState();
@@ -188,20 +191,40 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {
const { currentUserCan } = useUser();
- const togglePanel = ( { name: tabName }, isTabOpen ) => {
- const panelSwitching =
- tabName !== currentTab &&
- currentTab !== '' &&
- isTabOpen &&
- isPanelOpen;
+ // Single decision point for a tab click. Side-effect tabs (Preview store,
+ // Feedback CES modal) bail out before any panel state is touched. The
+ // rest of the logic decides open / close / switch from the parent's own
+ // state (currentTab, isPanelOpen) rather than the click target's intent
+ // — that way a focus-outside close racing with a same-tab click can't
+ // flip the panel back open after blur fires closePanel().
+ const togglePanel = ( tab ) => {
+ if ( tab.onClick ) {
+ tab.onClick();
+ return;
+ }
- if ( isPanelClosing ) {
+ const tabName = tab.name;
+ // Same-tab re-click during a pending close: do nothing. The close
+ // from useFocusOutside is already in flight; let it finish.
+ if ( isPanelClosing && tabName === currentTab ) {
return;
}
+ const isSameTab = tabName === currentTab;
+ const isClosing = isSameTab && isPanelOpen;
+ const isSwitching = ! isSameTab && currentTab !== '' && isPanelOpen;
+
+ // Record a Tracks event when a panel is being opened or switched in
+ // (not when closing). Previously the Tabs child fired this — moved
+ // here so it stays consistent with the rest of the intent logic.
+ if ( ! isClosing ) {
+ recordEvent( 'activity_panel_open', { tab: tabName } );
+ }
+
setCurrentTab( tabName );
- setIsPanelOpen( isTabOpen );
- setIsPanelSwitching( panelSwitching );
+ setIsPanelOpen( ! isClosing );
+ setIsPanelSwitching( isSwitching );
+ setIsPanelClosing( isClosing );
};
const isProductScreen = () => {
@@ -236,7 +259,20 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {
const activity = {
name: 'activity',
title: __( 'Activity', 'woocommerce' ),
- icon: <IconFlag />,
+ // Use bellUnread (bell + dot baked into the SVG) when there is
+ // unread activity so the unread state lives in one source of truth
+ // inside @wordpress/icons rather than a separately-positioned CSS
+ // pseudo-element on top of the plain bell.
+ icon: (
+ <Icon
+ icon={
+ hasUnreadNotes || hasAbbreviatedNotifications
+ ? bellUnread
+ : bell
+ }
+ size={ 18 }
+ />
+ ),
unread: hasUnreadNotes || hasAbbreviatedNotifications,
visible:
( isEmbedded || ! isHomescreen ) &&
@@ -248,7 +284,7 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {
const feedback = {
name: 'feedback',
title: __( 'Feedback', 'woocommerce' ),
- icon: <FeedbackIcon />,
+ icon: <Icon icon={ comment } size={ 18 } />,
onClick: () => {
setCurrentTab( 'feedback' );
setIsPanelOpen( true );
@@ -290,14 +326,7 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {
const setup = {
name: 'setup',
title: __( 'Finish setup', 'woocommerce' ),
- icon: (
- <SetupProgress
- setupTasksComplete={ setupTasksCompleteCount }
- setupCompletePercent={ Math.ceil(
- ( setupTasksCompleteCount / setupTasksCount ) * 100
- ) }
- />
- ),
+ icon: <Icon icon={ listView } size={ 18 } />,
visible:
currentUserCan( 'manage_woocommerce' ) &&
! requestingTaskListOptions &&
@@ -413,14 +442,7 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {
tabs={ tabs }
tabOpen={ isPanelOpen }
selectedTab={ currentTab }
- onTabClick={ ( tab, tabOpen ) => {
- if ( tab.onClick ) {
- tab.onClick();
- return;
- }
-
- togglePanel( tab, tabOpen );
- } }
+ onTabClick={ togglePanel }
/>
<Panel
currentTab
diff --git a/plugins/woocommerce/client/admin/client/activity-panel/style.scss b/plugins/woocommerce/client/admin/client/activity-panel/style.scss
index 781b42edc4f..f99e629b267 100644
--- a/plugins/woocommerce/client/admin/client/activity-panel/style.scss
+++ b/plugins/woocommerce/client/admin/client/activity-panel/style.scss
@@ -17,8 +17,8 @@
}
svg {
- width: 24px;
- height: 24px;
+ width: 18px;
+ height: 18px;
&.woocommerce-layout__activity-panel-tab-icon {
fill: none;
@@ -69,10 +69,16 @@
border: none;
outline: none;
cursor: pointer;
- background-color: $studio-white;
- width: 100%;
+ background-color: transparent;
+ // Match wp-admin admin-bar item padding so all five icons in the bar
+ // (bell / list / megaphone / gear / ?) sit at the same tight spacing.
+ width: auto;
+ min-width: 0;
+ padding: 0 8px;
height: $header-height;
- color: $gray-700;
+ // $gray-900 across all states (default, hover, active) — the blue
+ // underline alone signals active, matching Gutenberg's icon tab group.
+ color: $gray-900;
white-space: nowrap;
&::before {
@@ -91,54 +97,41 @@
&.is-active,
&.is-opened {
- color: $gray-900;
box-shadow: none;
&::before {
- height: 3px;
+ // 2px to match Gutenberg's TabPanel + Woo's own
+ // nav-tab-active indicator on Settings.
+ height: 2px;
opacity: 1;
}
}
- &.has-unread::after,
- &.woocommerce-layout__activity-panel-tab-wordpress-notices::after {
- content: " ";
- position: absolute;
- padding: 1px;
- background: $alert-red;
- border: 2px solid $studio-white;
- width: 4px;
- height: 4px;
- display: inline-block;
- border-radius: 50%;
- top: 8px;
- left: 50%;
-
- @include breakpoint( "782px-960px" ) {
- right: 18px;
- left: initial;
- margin-left: 0;
- }
-
- @include breakpoint( ">960px" ) {
- right: 28px;
- left: initial;
- margin-left: 0;
- }
+ // The unread state ships as `bellUnread` from @wordpress/icons — a
+ // single SVG with the dot baked in. We just colour the dot red so the
+ // urgency signal reads, instead of inheriting currentColor like the
+ // rest of the icon.
+ &.has-unread svg circle {
+ fill: $alert-red;
}
&:hover,
&.components-button:not(:disabled):not([aria-disabled="true"]):hover {
box-shadow: none;
-
- &.has-unread::after,
- &.woocommerce-layout__activity-panel-tab-wordpress-notices::after {
- border-color: $gray-200;
- }
+ // Override Button's default theme-blue hover — Gutenberg's own
+ // icon tab groups keep the same dark-grey across all states.
+ color: $gray-900;
}
+ // Suppress the inset focus shadow on mouse-click focus (it conflicts
+ // with the blue underline). Keyboard-only focus still shows via
+ // :focus-visible.
&:focus,
&.components-button:not(:disabled):not([aria-disabled="true"]):focus {
+ box-shadow: none;
+ }
+ &:focus-visible,
+ &.components-button:not(:disabled):not([aria-disabled="true"]):focus-visible {
box-shadow: inset -1px -1px 0 $gray-700, inset 1px 1px 0 $gray-700;
}
@@ -160,28 +153,6 @@
}
}
-.woocommerce-layout:has(.woocommerce-homescreen) {
- .woocommerce-layout__activity-panel-tabs {
- .woocommerce-layout__activity-panel-tab {
- color: var(--wp-admin-theme-color) !important;
- font-size: 13px;
- font-style: normal;
- font-weight: 400;
- line-height: 20px; /* 153.846% */
-
-
- &.has-icon {
- padding: 6px 12px;
- }
-
- svg {
- fill: #1e1e1e;
- }
- }
- }
-}
-
-
.woocommerce-layout__activity-panel-toggle-bubble.has-unread::after {
content: " ";
position: absolute;
@@ -216,6 +187,13 @@
}
.woocommerce-layout__activity-panel-wrapper {
+ // Suppress the browser's default focus outline on click focus (the blue
+ // ring with slight rounded corners). Keyboard nav still gets it via
+ // :focus-visible so a11y is preserved.
+ &:focus {
+ outline: none;
+ }
+
height: calc(100vh - #{$header-height + $adminbar-height-mobile});
background: $gray-100;
width: 430px;
@@ -226,7 +204,10 @@
@include activity-panel-slide();
position: absolute;
right: 0;
- top: 100%;
+ // 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});
z-index: 1000;
overflow-x: hidden;
overflow-y: auto;
diff --git a/plugins/woocommerce/client/admin/client/activity-panel/tab/index.js b/plugins/woocommerce/client/admin/client/activity-panel/tab/index.js
index 3fbd049570e..d00eb2175da 100644
--- a/plugins/woocommerce/client/admin/client/activity-panel/tab/index.js
+++ b/plugins/woocommerce/client/admin/client/activity-panel/tab/index.js
@@ -36,13 +36,13 @@ export const Tab = ( {
key={ tabKey }
id={ tabKey }
data-testid={ tabKey }
- aria-label={ ariaLabel }
+ label={ title || ariaLabel }
+ showTooltip
onClick={ () => {
onTabClick( name );
} }
>
{ icon }
- { title }{ ' ' }
{ unread && (
<span className="screen-reader-text">
{ __( 'unread activity', 'woocommerce' ) }
diff --git a/plugins/woocommerce/client/admin/client/activity-panel/tab/test/index.js b/plugins/woocommerce/client/admin/client/activity-panel/tab/test/index.js
index 10244a319e6..1d2a1fc332b 100644
--- a/plugins/woocommerce/client/admin/client/activity-panel/tab/test/index.js
+++ b/plugins/woocommerce/client/admin/client/activity-panel/tab/test/index.js
@@ -25,7 +25,7 @@ const renderTab = () =>
describe( 'ActivityPanel Tab', () => {
it( 'displays a title and unread status based on props', () => {
- const { getByText } = render(
+ const { getByRole, getByText } = render(
<Tab
icon={ null }
title={ 'Hello World' }
@@ -38,7 +38,12 @@ describe( 'ActivityPanel Tab', () => {
/>
);
- expect( getByText( 'Hello World' ) ).not.toBeNull();
+ // Title is exposed as the button's accessible name (aria-label) so
+ // screen readers announce it, even though icon-only tabs no longer
+ // render the title text visually.
+ expect(
+ getByRole( 'tab', { name: 'Hello World' } )
+ ).toBeInTheDocument();
expect( getByText( 'unread activity' ) ).not.toBeNull();
} );
diff --git a/plugins/woocommerce/client/admin/client/activity-panel/tabs/index.js b/plugins/woocommerce/client/admin/client/activity-panel/tabs/index.js
index 006df7e9010..94ab397b478 100644
--- a/plugins/woocommerce/client/admin/client/activity-panel/tabs/index.js
+++ b/plugins/woocommerce/client/admin/client/activity-panel/tabs/index.js
@@ -2,33 +2,26 @@
* External dependencies
*/
import { NavigableMenu } from '@wordpress/components';
-import { useEffect, useState } from '@wordpress/element';
-import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { Tab } from '../tab';
+/**
+ * Renders the activity-panel tab strip. Decisions about what a click means
+ * (open / close / switch) live in the parent's `onTabClick` handler, not
+ * here — Tabs just forwards the clicked tab and renders selection state
+ * from props. Previously this component maintained its own mirror of the
+ * panel's open state and computed click intent from it, which created a
+ * one-frame gap where a same-tab close could pop back open mid-animation.
+ */
export const Tabs = ( {
tabs,
onTabClick,
selectedTab: selectedTabName,
tabOpen = false,
} ) => {
- const [ { tabOpen: tabIsOpenState, currentTab }, setTabState ] = useState( {
- tabOpen,
- currentTab: selectedTabName,
- } );
-
- // Keep state synced with props
- useEffect( () => {
- setTabState( {
- tabOpen,
- currentTab: selectedTabName,
- } );
- }, [ tabOpen, selectedTabName ] );
-
return (
<NavigableMenu
role="tablist"
@@ -45,28 +38,10 @@ export const Tabs = ( {
<Tab
key={ i }
index={ i }
- isPanelOpen={ tabIsOpenState }
- selected={ currentTab === tab.name }
+ isPanelOpen={ tabOpen }
+ selected={ selectedTabName === tab.name }
{ ...tab }
- onTabClick={ () => {
- const isTabOpen =
- currentTab === tab.name || currentTab === ''
- ? ! tabIsOpenState
- : true;
-
- // If a panel is being opened, or if an existing panel is already open and a different one is being opened, record a track.
- if ( ! isTabOpen || currentTab !== tab.name ) {
- recordEvent( 'activity_panel_open', {
- tab: tab.name,
- } );
- }
-
- setTabState( {
- tabOpen: isTabOpen,
- currentTab: tab.name,
- } );
- onTabClick( tab, isTabOpen );
- } }
+ onTabClick={ () => onTabClick( tab ) }
/>
);
} ) }
diff --git a/plugins/woocommerce/client/admin/client/activity-panel/tabs/test/index.js b/plugins/woocommerce/client/admin/client/activity-panel/tabs/test/index.js
index ca0f6afef62..c95f0fca148 100644
--- a/plugins/woocommerce/client/admin/client/activity-panel/tabs/test/index.js
+++ b/plugins/woocommerce/client/admin/client/activity-panel/tabs/test/index.js
@@ -2,14 +2,13 @@
* External dependencies
*/
import { render, fireEvent } from '@testing-library/react';
-import { recordEvent } from '@woocommerce/tracks';
+import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { Tabs } from '../';
-jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
const generateTabs = () => {
return [ '0', '1', '2', '3' ].map( ( name ) => ( {
name,
@@ -23,35 +22,47 @@ const CustomTab = () => {
return <div>Custom Tab</div>;
};
+// Test wrapper that mirrors the parent ActivityPanel's open/close/switch
+// intent decisions. Tabs is now a controlled component — selection state
+// lives in the parent — so its rendered selection class is only correct
+// when the parent updates `selectedTab` / `tabOpen` in response to clicks.
+const ControlledTabs = ( { initialSelected = '', tabs } ) => {
+ const [ selected, setSelected ] = useState( initialSelected );
+ const [ open, setOpen ] = useState( !! initialSelected );
+ return (
+ <Tabs
+ selectedTab={ selected }
+ tabOpen={ open }
+ tabs={ tabs }
+ onTabClick={ ( tab ) => {
+ const isSameTab = tab.name === selected;
+ const isClosing = isSameTab && open;
+ setSelected( tab.name );
+ setOpen( ! isClosing );
+ } }
+ />
+ );
+};
+
describe( 'Activity Panel Tabs', () => {
- it( 'correctly tracks the selected tab', () => {
+ it( 'renders the selected tab as active when both selectedTab and tabOpen prop in', () => {
const { getAllByRole } = render(
- <Tabs
- selectedTab={ '3' }
- tabs={ generateTabs() }
- onTabClick={ () => {} }
- />
+ <ControlledTabs initialSelected="3" tabs={ generateTabs() } />
);
const tabs = getAllByRole( 'tab' );
fireEvent.click( tabs[ 2 ] );
-
expect( tabs[ 2 ] ).toHaveClass( 'is-active' );
fireEvent.click( tabs[ 3 ] );
-
expect( tabs[ 2 ] ).not.toHaveClass( 'is-active' );
expect( tabs[ 3 ] ).toHaveClass( 'is-active' );
} );
- it( 'closes a tab if its the same one last opened', () => {
+ it( 'unsets is-active when the same tab is clicked twice in a row', () => {
const { getAllByRole } = render(
- <Tabs
- selectedTab={ '3' }
- tabs={ generateTabs() }
- onTabClick={ () => {} }
- />
+ <ControlledTabs initialSelected="3" tabs={ generateTabs() } />
);
const tabs = getAllByRole( 'tab' );
@@ -62,7 +73,7 @@ describe( 'Activity Panel Tabs', () => {
expect( tabs[ 2 ] ).not.toHaveClass( 'is-active' );
} );
- it( 'triggers onTabClick with the selected when a tab is clicked', () => {
+ it( 'forwards the clicked tab to onTabClick', () => {
const tabClickSpy = jest.fn();
const generatedTabs = generateTabs();
@@ -78,27 +89,7 @@ describe( 'Activity Panel Tabs', () => {
fireEvent.click( tabs[ 3 ] );
- expect( tabClickSpy ).toHaveBeenCalledWith( generatedTabs[ 3 ], true );
- } );
-
- it( 'records an event when panels are being opened and when the open panel changes', () => {
- const generatedTabs = generateTabs();
-
- const { getAllByRole } = render(
- <Tabs
- selectedTab={ '3' }
- tabs={ generatedTabs }
- onTabClick={ () => {} }
- />
- );
-
- const tabs = getAllByRole( 'tab' );
-
- fireEvent.click( tabs[ 3 ] );
-
- expect( recordEvent ).toHaveBeenCalledWith( 'activity_panel_open', {
- tab: generatedTabs[ 3 ].name,
- } );
+ expect( tabClickSpy ).toHaveBeenCalledWith( generatedTabs[ 3 ] );
} );
it( 'should render tabs with a custom component defined in tab config', () => {
diff --git a/plugins/woocommerce/client/admin/client/activity-panel/test/index.js b/plugins/woocommerce/client/admin/client/activity-panel/test/index.js
index 06db14fb6ab..60ff1e7a5f9 100644
--- a/plugins/woocommerce/client/admin/client/activity-panel/test/index.js
+++ b/plugins/woocommerce/client/admin/client/activity-panel/test/index.js
@@ -95,7 +95,7 @@ describe( 'Activity Panel', () => {
it( 'should render inbox tab on embedded pages', () => {
render( <ActivityPanel isEmbedded query={ {} } /> );
- expect( screen.getByText( 'Activity' ) ).toBeDefined();
+ expect( screen.getByRole( 'tab', { name: 'Activity' } ) ).toBeDefined();
} );
it( 'should render inbox tab if not on home screen', () => {
@@ -103,19 +103,21 @@ describe( 'Activity Panel', () => {
<ActivityPanel query={ { page: 'wc-admin', path: '/customers' } } />
);
- expect( screen.getByText( 'Activity' ) ).toBeDefined();
+ expect( screen.getByRole( 'tab', { name: 'Activity' } ) ).toBeDefined();
} );
it( 'should not render inbox tab on home screen', () => {
render( <ActivityPanel query={ { page: 'wc-admin' } } /> );
- expect( screen.queryByText( 'Inbox' ) ).toBeNull();
+ expect( screen.queryByRole( 'tab', { name: 'Inbox' } ) ).toBeNull();
} );
it( 'should render preview store tab on home screen', () => {
render( <ActivityPanel query={ { page: 'wc-admin' } } /> );
- expect( screen.getByText( 'Preview store' ) ).toBeDefined();
+ expect(
+ screen.getByRole( 'tab', { name: 'Preview store' } )
+ ).toBeDefined();
} );
it( 'should not render help tab if not on home screen', () => {
@@ -167,7 +169,7 @@ describe( 'Activity Panel', () => {
);
// Expect that "Help" tab is absent.
- expect( screen.queryByText( 'Help' ) ).toBeNull();
+ expect( screen.queryByRole( 'tab', { name: 'Help' } ) ).toBeNull();
} );
it( 'should render display options if on home screen', () => {
@@ -183,7 +185,7 @@ describe( 'Activity Panel', () => {
} );
it( 'should only render the finish setup link when TaskList is not complete', () => {
- const { queryByText, rerender } = render(
+ const { queryByRole, rerender } = render(
<ActivityPanel
query={ {
task: 'products',
@@ -191,7 +193,7 @@ describe( 'Activity Panel', () => {
/>
);
- expect( queryByText( 'Finish setup' ) ).toBeDefined();
+ expect( queryByRole( 'tab', { name: 'Finish setup' } ) ).toBeDefined();
useSelect.mockImplementation( () => ( {
requestingTaskListOptions: false,
@@ -207,11 +209,11 @@ describe( 'Activity Panel', () => {
/>
);
- expect( queryByText( 'Finish setup' ) ).toBeNull();
+ expect( queryByRole( 'tab', { name: 'Finish setup' } ) ).toBeNull();
} );
it( 'should not render the finish setup link when on the home screen and TaskList is not complete', () => {
- const { queryByText } = render(
+ const { queryByRole } = render(
<ActivityPanel
query={ {
page: 'wc-admin',
@@ -220,15 +222,17 @@ describe( 'Activity Panel', () => {
/>
);
- expect( queryByText( 'Finish setup' ) ).toBeNull();
+ expect( queryByRole( 'tab', { name: 'Finish setup' } ) ).toBeNull();
} );
it( 'should render the finish setup link when on embedded pages and TaskList is not complete', () => {
- const { getByText } = render(
+ const { getByRole } = render(
<ActivityPanel isEmbedded query={ {} } />
);
- expect( getByText( 'Finish setup' ) ).toBeInTheDocument();
+ expect(
+ getByRole( 'tab', { name: 'Finish setup' } )
+ ).toBeInTheDocument();
} );
it( 'should not render the finish setup link when a user does not have capabilities', () => {
@@ -236,7 +240,7 @@ describe( 'Activity Panel', () => {
currentUserCan: () => false,
} ) );
- const { queryByText } = render(
+ const { queryByRole } = render(
<ActivityPanel
query={ {
task: 'products',
@@ -244,7 +248,7 @@ describe( 'Activity Panel', () => {
/>
);
- expect( queryByText( 'Finish setup' ) ).toBeDefined();
+ expect( queryByRole( 'tab', { name: 'Finish setup' } ) ).toBeDefined();
} );
describe( 'panel', () => {
diff --git a/plugins/woocommerce/client/admin/client/header/embed.tsx b/plugins/woocommerce/client/admin/client/header/embed.tsx
index ca73a1b6a9c..e76b74d0208 100644
--- a/plugins/woocommerce/client/admin/client/header/embed.tsx
+++ b/plugins/woocommerce/client/admin/client/header/embed.tsx
@@ -1,9 +1,18 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+import { __ } from '@wordpress/i18n';
+import { Button, Icon } from '@wordpress/components';
+import { cog, help } from '@wordpress/icons';
+
/**
* Internal dependencies
*/
import './style.scss';
import { isTaskListActive } from '~/hooks/use-tasklists-state';
import { BaseHeader } from './shared';
+import { useWpAdminChrome } from './use-wp-admin-chrome';
export const EmbedHeader = ( {
sections,
@@ -19,12 +28,70 @@ export const EmbedHeader = ( {
isTaskListActive( 'setup' ) && ! isReactifyPaymentsSettingsScreen
);
+ // Embed pages live on top of classic wp-admin screens. Detect the wp-admin
+ // chrome wp-admin already rendered so we can suppress the duplicate <h1>,
+ // proxy the Screen Options / Help dropdowns through floating-header icons,
+ // and collapse the bar to chrome-only height when there is no title to show.
+ const {
+ hasH1: hasWpAdminH1,
+ hasScreenOptions,
+ hasContextualHelp,
+ activeMetaIcon,
+ triggerMetaIcon,
+ } = useWpAdminChrome( query );
+
+ const trailingItems = (
+ <>
+ { /* Screen Options + Help icons consolidated into the floating
+ header. Only rendered when wp-admin would have rendered the
+ corresponding entry point. The original wp-admin wraps are
+ visually hidden via CSS and these icons proxy clicks into them
+ through triggerMetaIcon. */ }
+ { hasScreenOptions && (
+ <Button
+ className={ clsx( 'woocommerce-layout__header-meta-icon', {
+ 'is-active': activeMetaIcon === 'screen-options',
+ } ) }
+ label={ __( 'Screen options', 'woocommerce' ) }
+ aria-expanded={ activeMetaIcon === 'screen-options' }
+ showTooltip
+ onClick={ () =>
+ triggerMetaIcon(
+ 'screen-options',
+ '#show-settings-link'
+ )
+ }
+ >
+ <Icon icon={ cog } size={ 18 } />
+ </Button>
+ ) }
+ { hasContextualHelp && (
+ <Button
+ className={ clsx( 'woocommerce-layout__header-meta-icon', {
+ 'is-active': activeMetaIcon === 'help',
+ } ) }
+ label={ __( 'Help', 'woocommerce' ) }
+ aria-expanded={ activeMetaIcon === 'help' }
+ showTooltip
+ onClick={ () =>
+ triggerMetaIcon( 'help', '#contextual-help-link' )
+ }
+ >
+ <Icon icon={ help } size={ 18 } />
+ </Button>
+ ) }
+ </>
+ );
+
return (
<BaseHeader
isEmbedded={ true }
query={ query }
sections={ sections }
showReminderBar={ showReminderBar }
+ suppressTitle={ hasWpAdminH1 }
+ compact={ hasWpAdminH1 }
+ trailingItems={ trailingItems }
/>
);
};
diff --git a/plugins/woocommerce/client/admin/client/header/shared.tsx b/plugins/woocommerce/client/admin/client/header/shared.tsx
index b1cb5a2b981..00a705c29fb 100644
--- a/plugins/woocommerce/client/admin/client/header/shared.tsx
+++ b/plugins/woocommerce/client/admin/client/header/shared.tsx
@@ -84,6 +84,15 @@ export const getPageTitle = ( sections: string[] ) => {
return pageTitle;
};
+/**
+ * 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, slot rendering, and
+ * the optional reminder bar. 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
+ * `trailingItems` slot.
+ */
export const BaseHeader = ( {
isEmbedded,
query,
@@ -91,6 +100,9 @@ export const BaseHeader = ( {
sections,
children,
leftAlign = true,
+ suppressTitle = false,
+ compact = false,
+ trailingItems,
}: {
isEmbedded: boolean;
query: Record< string, string >;
@@ -98,6 +110,24 @@ export const BaseHeader = ( {
sections: string[];
children?: React.ReactNode;
leftAlign?: boolean;
+ /**
+ * When true, render a spacer instead of the title. Caller (EmbedHeader)
+ * sets this on classic post-type screens where wp-admin already renders
+ * its own <h1>. Page-title slot fills always win over this flag — if a
+ * fill is registered, it renders regardless.
+ */
+ suppressTitle?: boolean;
+ /**
+ * When true, collapse the bar to admin-bar height. Used in tandem with
+ * `suppressTitle` to give the bar a chrome-only treatment.
+ */
+ compact?: boolean;
+ /**
+ * Items rendered at the right edge, after WooHeaderItem.Slot. EmbedHeader
+ * uses this for the gear / ? icons that proxy clicks into wp-admin's
+ * Screen Options and Help dropdowns.
+ */
+ trailingItems?: React.ReactNode;
} ) => {
const { isScrolled } = useIsScrolled();
@@ -110,10 +140,16 @@ export const BaseHeader = ( {
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 }
>
@@ -128,25 +164,37 @@ export const BaseHeader = ( {
fillProps={ { isEmbedded, query } }
/>
- <Text
- className={ clsx( 'woocommerce-layout__header-heading', {
- 'woocommerce-layout__header-left-align': leftAlign,
- } ) }
- as="h1"
- >
- { decodeEntities(
- hasPageTitleFills ? (
+ { shouldRenderTitle ? (
+ <Text
+ className={ clsx(
+ 'woocommerce-layout__header-heading',
+ {
+ 'woocommerce-layout__header-left-align':
+ leftAlign,
+ }
+ ) }
+ as="h1"
+ >
+ { hasPageTitleFills ? (
<WooHeaderPageTitle.Slot
fillProps={ { isEmbedded, query } }
/>
) : (
- getPageTitle( sections )
- )
- ) }
- </Text>
+ decodeEntities( getPageTitle( sections ) )
+ ) }
+ </Text>
+ ) : (
+ // Spacer keeps WooHeaderItem.Slot pinned right when no
+ // title renders.
+ <div
+ className="woocommerce-layout__header-spacer"
+ aria-hidden="true"
+ />
+ ) }
{ children }
<WooHeaderItem.Slot fillProps={ { isEmbedded, query } } />
+ { trailingItems }
</div>
</div>
);
diff --git a/plugins/woocommerce/client/admin/client/header/style.scss b/plugins/woocommerce/client/admin/client/header/style.scss
index 921e6043767..48c5be76a7f 100644
--- a/plugins/woocommerce/client/admin/client/header/style.scss
+++ b/plugins/woocommerce/client/admin/client/header/style.scss
@@ -1,5 +1,56 @@
+// Standard visually-hidden pattern — keeps the element in the layout tree
+// (so jQuery delegated handlers still fire on it) but moves it off-screen
+// and clips its rendered area to nothing. Used twice in this file for the
+// activity-panel tab title spans and the wp-admin meta-toggle wraps.
+@mixin visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ border: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+// Hide ONLY the two standard wp-admin meta-toggle wraps (Screen Options +
+// Help) that we proxy via the floating header's gear / ? icons. The parent
+// #screen-meta-links container is left alone so any third-party sibling
+// buttons (the rare plugin that injects its own `.screen-meta-toggle` div
+// directly into the strip) remain visible. Visually-hidden pattern (rather
+// than display:none) keeps the elements in the layout tree so wp-admin's
+// jQuery delegated click handlers still fire when we proxy .click() into
+// #show-settings-link / #contextual-help-link.
+// Defensive guard: only hide when the floating header's meta-icon button
+// has actually rendered (`:has(.woocommerce-layout__header-meta-icon)`).
+// If hasScreenOptions / hasContextualHelp detection misses for any reason,
+// :has() doesn't match and the user keeps access to the original wp-admin
+// strip rather than losing it entirely. Browsers without :has() support
+// (Firefox <121) also fall through to the safe state.
+.woocommerce-admin-page:has(.woocommerce-layout__header-meta-icon),
+body.woocommerce_page_wc-admin:has(.woocommerce-layout__header-meta-icon) {
+ #screen-options-link-wrap,
+ #contextual-help-link-wrap {
+ @include visually-hidden;
+ }
+}
+
+// Chrome-only narrow-bar height (Edit Order, Edit Product, Add Product, etc.).
+// Now that activity-panel tabs are icon-only globally, they fit cleanly in 32px.
+$woo-chrome-only-header-height: 32px;
+
.woocommerce-layout__header {
- background: $studio-white;
+ // 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
+ // 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.
+ background: $wp-admin-background;
+ border-bottom: $header-border-width solid $wp-admin-border;
box-sizing: border-box;
padding: 0;
position: fixed;
@@ -39,7 +90,7 @@
padding: 0 0 0 $fallback-gutter-large;
padding: 0 0 0 $gutter-large;
height: $header-height;
- background: $studio-white;
+ background: transparent;
font-weight: 600;
font-size: 14px;
@@ -47,6 +98,108 @@
flex: 1 auto;
}
}
+
+ // Spacer that takes up the title's flex space when no h1 renders
+ // (embedded post-type screens), so right-side items stay pinned right.
+ .woocommerce-layout__header-spacer {
+ flex: 1 auto;
+ }
+
+ // Screen Options + Help icon buttons — visually mirror the
+ // activity-panel-tab pattern so bell / list / megaphone / gear / ? all
+ // look identical regardless of which subsystem renders them, including
+ // the blue ::before underline on the active tab.
+ .woocommerce-layout__header-meta-icon {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ position: relative;
+ border: none;
+ outline: none;
+ cursor: pointer;
+ background-color: transparent;
+ height: $header-height;
+ // wp-admin admin-bar items use ~0 8px horizontal padding each side;
+ // match it for a tight, infrastructure-y spacing.
+ min-width: 0;
+ padding: 0 8px;
+ // $gray-900 across all states (default, hover, active) — the blue
+ // underline alone signals active, matching Gutenberg's icon tab group.
+ color: $gray-900;
+
+ &::before {
+ background-color: var(--wp-admin-theme-color);
+ bottom: 0;
+ content: "";
+ height: 0;
+ opacity: 0;
+ transition-property: height, opacity;
+ transition-duration: 300ms;
+ transition-timing-function: cubic-bezier(0.42, 0, 0.58, 1);
+ left: 0;
+ position: absolute;
+ right: 0;
+ }
+
+ &.is-active {
+ box-shadow: none;
+
+ &::before {
+ // 2px to match Gutenberg's TabPanel + Woo's own
+ // nav-tab-active indicator on Settings.
+ height: 2px;
+ opacity: 1;
+ }
+ }
+
+ &:hover,
+ &.components-button:not(:disabled):not([aria-disabled="true"]):hover {
+ box-shadow: none;
+ background-color: transparent;
+ // Override Button's default theme-blue hover — Gutenberg's own
+ // icon tab groups keep the same dark-grey across all states.
+ color: $gray-900;
+ }
+
+ // Suppress the inset focus shadow on mouse-click focus (it conflicts
+ // with the blue underline). Keyboard-only focus still shows a ring
+ // via :focus-visible, preserving accessibility.
+ &:focus,
+ &.components-button:not(:disabled):not([aria-disabled="true"]):focus {
+ box-shadow: none;
+ }
+ &:focus-visible,
+ &.components-button:not(:disabled):not([aria-disabled="true"]):focus-visible {
+ box-shadow: inset -1px -1px 0 $gray-700,
+ inset 1px 1px 0 $gray-700;
+ }
+
+ svg {
+ fill: currentColor;
+ }
+ }
+
+ // Chrome-only treatment: bar collapses to 32px on pages without a title.
+ // Hard height + max-height + overflow:hidden so the wrapper can't be pushed
+ // 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-layout__header-wrapper {
+ height: $woo-chrome-only-header-height;
+ min-height: $woo-chrome-only-header-height;
+ max-height: $woo-chrome-only-header-height;
+ overflow: hidden;
+ }
+
+ .woocommerce-layout__activity-panel-tabs,
+ .woocommerce-layout__activity-panel-tab,
+ .woocommerce-layout__header-meta-icon {
+ height: $woo-chrome-only-header-height;
+ min-height: $woo-chrome-only-header-height;
+ max-height: $woo-chrome-only-header-height;
+ }
+ }
}
.folded .woocommerce-layout__header {
diff --git a/plugins/woocommerce/client/admin/client/header/use-wp-admin-chrome.ts b/plugins/woocommerce/client/admin/client/header/use-wp-admin-chrome.ts
new file mode 100644
index 00000000000..8b6240a2efa
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/header/use-wp-admin-chrome.ts
@@ -0,0 +1,179 @@
+/**
+ * External dependencies
+ */
+import { useEffect, useState } from '@wordpress/element';
+
+type ActiveMetaIcon = 'screen-options' | 'help' | null;
+
+type WpAdminChrome = {
+ hasH1: boolean;
+ hasScreenOptions: boolean;
+ hasContextualHelp: boolean;
+ activeMetaIcon: ActiveMetaIcon;
+ triggerMetaIcon: (
+ which: Exclude< ActiveMetaIcon, null >,
+ triggerId: string
+ ) => void;
+};
+
+/**
+ * Detect and orchestrate wp-admin chrome that's already rendered on classic
+ * admin pages (Edit Order, Edit Product, Settings, etc.). On a non-embedded
+ * Woo-custom page the queried elements aren't in the DOM and every return
+ * value falls back to a no-op default.
+ *
+ * Three signals are read synchronously on mount and re-read on route changes:
+ * - `.wrap > h1.wp-heading-inline` — wp-admin's own page title. When present
+ * the embed header should suppress its own <h1> to avoid duplicating it.
+ * - `#screen-options-link-wrap` and `#contextual-help-link-wrap` — the
+ * wp-admin meta-toggle wraps. When present we render proxy gear / ? icons
+ * in the floating header and hide the originals via CSS.
+ *
+ * The hook also owns the meta-icon orchestration:
+ * - `activeMetaIcon` stays in sync with each trigger's `aria-expanded` via a
+ * `MutationObserver`, so React state updates only happen *after* a click
+ * has fully settled — never during.
+ * - `triggerMetaIcon` closes any open activity-panel tab and the other
+ * wp-admin dropdown before opening the target one (mutual exclusion).
+ * - A document-level capture-phase click listener closes any open wp-admin
+ * dropdown when an activity-panel tab is clicked. (Sync goes both ways.)
+ *
+ * Lazy initial state reads the DOM on first render so embed pages don't flash
+ * a duplicate-title frame before the first effect commits.
+ */
+export const useWpAdminChrome = (
+ query: Record< string, string >
+): WpAdminChrome => {
+ const detectWpAdminChrome = () => ( {
+ hasH1: !! document.querySelector( '.wrap > h1.wp-heading-inline' ),
+ hasScreenOptions: !! document.querySelector(
+ '#screen-options-link-wrap'
+ ),
+ hasContextualHelp: !! document.querySelector(
+ '#contextual-help-link-wrap'
+ ),
+ } );
+ const [ chrome, setChrome ] = useState( detectWpAdminChrome );
+ useEffect( () => {
+ setChrome( detectWpAdminChrome() );
+ }, [ query ] );
+ const { hasH1, hasScreenOptions, hasContextualHelp } = chrome;
+
+ const [ activeMetaIcon, setActiveMetaIcon ] =
+ useState< ActiveMetaIcon >( null );
+
+ // Reverse-direction sync: when an activity-panel tab is clicked AND a
+ // wp-admin dropdown is currently open, close the dropdown. We don't update
+ // React state from this handler (state syncs reactively via the
+ // MutationObserver below), so no setTimeout deferral is needed.
+ useEffect( () => {
+ const handler = ( e: Event ) => {
+ const target = e.target as HTMLElement | null;
+ if (
+ ! target?.closest( '.woocommerce-layout__activity-panel-tab' )
+ ) {
+ return;
+ }
+ document
+ .querySelector< HTMLButtonElement >(
+ '#show-settings-link[aria-expanded="true"]'
+ )
+ ?.click();
+ document
+ .querySelector< HTMLButtonElement >(
+ '#contextual-help-link[aria-expanded="true"]'
+ )
+ ?.click();
+ };
+ document.addEventListener( 'click', handler, true );
+ return () => document.removeEventListener( 'click', handler, true );
+ }, [] );
+
+ // Keep activeMetaIcon in sync with the actual wp-admin dropdown state by
+ // observing aria-expanded changes on the trigger buttons.
+ useEffect( () => {
+ if ( ! hasScreenOptions && ! hasContextualHelp ) {
+ setActiveMetaIcon( null );
+ return;
+ }
+ const screenOptBtn = document.querySelector< HTMLButtonElement >(
+ '#show-settings-link'
+ );
+ const helpBtn = document.querySelector< HTMLButtonElement >(
+ '#contextual-help-link'
+ );
+ const sync = () => {
+ const screenOpen =
+ screenOptBtn?.getAttribute( 'aria-expanded' ) === 'true';
+ const helpOpen =
+ helpBtn?.getAttribute( 'aria-expanded' ) === 'true';
+ let next: ActiveMetaIcon = null;
+ if ( screenOpen ) {
+ next = 'screen-options';
+ } else if ( helpOpen ) {
+ next = 'help';
+ }
+ setActiveMetaIcon( next );
+ };
+ sync();
+ const observer = new MutationObserver( sync );
+ const opts = {
+ attributes: true,
+ attributeFilter: [ 'aria-expanded' ],
+ };
+ if ( screenOptBtn ) observer.observe( screenOptBtn, opts );
+ if ( helpBtn ) observer.observe( helpBtn, opts );
+ return () => observer.disconnect();
+ }, [ hasScreenOptions, hasContextualHelp ] );
+
+ const triggerMetaIcon = (
+ which: Exclude< ActiveMetaIcon, null >,
+ triggerId: string
+ ) => {
+ // Close any open activity-panel tab so the five icons act as one group.
+ document
+ .querySelector< HTMLButtonElement >(
+ '.woocommerce-layout__activity-panel-tab.is-active'
+ )
+ ?.click();
+ // Close the OTHER wp-admin dropdown if open (mutual exclusion between
+ // gear ↔ help). Chain the new open off the closing trigger's
+ // aria-expanded flip rather than a magic-number setTimeout — wp-admin's
+ // screen-meta.js sets aria-expanded synchronously when its handler fires,
+ // so the observer fires as soon as the close has registered, regardless
+ // of however long the slideUp animation takes. Self-disconnects on first
+ // flip so back-to-back clicks don't accumulate observers.
+ const otherTriggerId =
+ which === 'screen-options'
+ ? '#contextual-help-link'
+ : '#show-settings-link';
+ const otherOpen = document.querySelector< HTMLButtonElement >(
+ `${ otherTriggerId }[aria-expanded="true"]`
+ );
+ const openTarget = () =>
+ document.querySelector< HTMLButtonElement >( triggerId )?.click();
+ if ( otherOpen ) {
+ const chain = new MutationObserver( () => {
+ if ( otherOpen.getAttribute( 'aria-expanded' ) !== 'true' ) {
+ chain.disconnect();
+ openTarget();
+ }
+ } );
+ chain.observe( otherOpen, {
+ attributes: true,
+ attributeFilter: [ 'aria-expanded' ],
+ } );
+ otherOpen.click();
+ } else {
+ openTarget();
+ }
+ };
+
+ return {
+ hasH1,
+ hasScreenOptions,
+ hasContextualHelp,
+ activeMetaIcon,
+ triggerMetaIcon,
+ };
+};
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index 5783efacd96..1f94df85062 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -9329,6 +9329,15 @@ body.woocommerce_page_wc-settings {
padding-bottom: 0px;
}
+ // Floating header on Settings sits directly above the tab nav with no
+ // content between — treat both strips as one continuous title region
+ // by dropping the header's bottom border. The tab strip keeps its own
+ // bottom border (separating tabs from content); we just colour it to
+ // match the rest of the chrome.
+ .woocommerce-layout__header {
+ border-bottom: 0;
+ }
+
p.submit {
margin-bottom: 0;
}
@@ -9435,8 +9444,13 @@ body.woocommerce_page_wc-settings {
}
}
margin: 0;
- background: #FFF;
- border-bottom: 1px solid #EBEBEB;
+ // Tab strip inherits the wp-admin body bg so it reads as one
+ // continuous title region with the floating header above. Bottom
+ // border colour matches the rest of the wp-admin chrome borders
+ // ($wp-admin-border in packages/js/internal-style-build); kept as
+ // a literal here because legacy admin.scss doesn't import the
+ // shared variables file.
+ border-bottom: 1px solid #dcdcde;
.nav-tab-active, .nav-tab-active:focus, .nav-tab-active:focus:active, .nav-tab-active:hover {
border-bottom: 2px solid var(--wp-admin-theme-color, #3858e9);
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/order/order-edit.spec.ts b/plugins/woocommerce/tests/e2e-pw/tests/order/order-edit.spec.ts
index 78bada4620f..a485af0204a 100644
--- a/plugins/woocommerce/tests/e2e-pw/tests/order/order-edit.spec.ts
+++ b/plugins/woocommerce/tests/e2e-pw/tests/order/order-edit.spec.ts
@@ -101,9 +101,9 @@ test.describe( 'Edit order', { tag: [ tags.SERVICES, tags.HPOS ] }, () => {
}
// confirm we're on the orders page
- await expect( page.locator( 'h1.components-text' ) ).toContainText(
- 'Orders'
- );
+ await expect(
+ page.locator( 'h1.components-text, h1.wp-heading-inline' )
+ ).toContainText( 'Orders' );
// open order we created
await page.goto(
`wp-admin/admin.php?page=wc-orders&action=edit&id=${ orderId }`