diff --git a/packages/block-editor/src/hooks/color.js b/packages/block-editor/src/hooks/color.js
index 82b0e8d785df15..f98e66a29b6fc1 100644
--- a/packages/block-editor/src/hooks/color.js
+++ b/packages/block-editor/src/hooks/color.js
@@ -31,14 +31,9 @@ import {
useHasColorPanel,
default as StylesColorPanel,
} from '../components/global-styles/color-panel';
-import { extractColorSlug } from '../utils/color-values';
import BlockColorContrastChecker from './contrast-checker';
+import PseudoStateContrastChecker from './pseudo-state-contrast-checker';
import { store as blockEditorStore } from '../store';
-import {
- getStyleForState,
- setStyleForState,
- useBlockStyleState,
-} from './block-style-state';
export const COLOR_SUPPORT_KEY = 'color';
@@ -69,7 +64,6 @@ const hasLinkColorSupport = ( blockType ) => {
const hasGradientSupport = ( blockNameOrType ) => {
const colorSupport = getBlockSupport( blockNameOrType, COLOR_SUPPORT_KEY );
-
return (
colorSupport !== null &&
typeof colorSupport === 'object' &&
@@ -79,13 +73,11 @@ const hasGradientSupport = ( blockNameOrType ) => {
const hasBackgroundColorSupport = ( blockType ) => {
const colorSupport = getBlockSupport( blockType, COLOR_SUPPORT_KEY );
-
return colorSupport && colorSupport.background !== false;
};
const hasTextColorSupport = ( blockType ) => {
const colorSupport = getBlockSupport( blockType, COLOR_SUPPORT_KEY );
-
return colorSupport && colorSupport.text !== false;
};
@@ -197,9 +189,17 @@ export function addSaveProps( props, blockNameOrType, attributes ) {
function styleToAttributes( style ) {
const textColorValue = style?.color?.text;
- const textColorSlug = extractColorSlug( textColorValue );
+ const textColorSlug = textColorValue?.startsWith( 'var:preset|color|' )
+ ? textColorValue.substring( 'var:preset|color|'.length )
+ : undefined;
+
const backgroundColorValue = style?.color?.background;
- const backgroundColorSlug = extractColorSlug( backgroundColorValue );
+ const backgroundColorSlug = backgroundColorValue?.startsWith(
+ 'var:preset|color|'
+ )
+ ? backgroundColorValue.substring( 'var:preset|color|'.length )
+ : undefined;
+
const gradientValue = style?.color?.gradient;
const gradientSlug = gradientValue?.startsWith( 'var:preset|gradient|' )
? gradientValue.substring( 'var:preset|gradient|'.length )
@@ -260,6 +260,24 @@ function ColorInspectorControl( { children, resetAllFilter } ) {
);
}
+/**
+ * @typedef {'default'|':hover'|':focus'|':focus-visible'|':active'} PseudoState
+ */
+
+/**
+ * Renders the color inspector controls for a block, including per-pseudo-state
+ * contrast checking when an interactive state panel is active.
+ *
+ * @param {Object} props
+ * @param {string} props.clientId Block client ID.
+ * @param {string} props.name Block name.
+ * @param {Function} props.setAttributes Block setAttributes.
+ * @param {Object} props.settings Color settings passed from block support.
+ * @param {React.ElementType} [props.asWrapper] Optional custom wrapper.
+ * @param {string} [props.label] Panel label.
+ * @param {Object} [props.defaultControls] Which controls are open by default.
+ * @param {PseudoState} [props.activePseudoState] Active interactive state, e.g. ':hover'.
+ */
export function ColorEdit( {
clientId,
name,
@@ -268,10 +286,9 @@ export function ColorEdit( {
asWrapper,
label,
defaultControls,
+ activePseudoState,
} ) {
- const selectedState = useBlockStyleState();
const isEnabled = useHasColorPanel( settings );
-
const { style, textColor, backgroundColor, gradient } = useSelect(
( select ) => {
// Early return to avoid subscription when disabled
@@ -294,38 +311,18 @@ export function ColorEdit( {
[ clientId, isEnabled ]
);
- const isStateSelected = selectedState !== 'default';
-
const value = useMemo( () => {
- if ( isStateSelected ) {
- return getStyleForState( style, selectedState );
- }
return attributesToStyle( {
style,
textColor,
backgroundColor,
gradient,
} );
- }, [
- isStateSelected,
- selectedState,
- style,
- textColor,
- backgroundColor,
- gradient,
- ] );
-
- const onChange = isStateSelected
- ? ( newStyle ) => {
- setAttributes( {
- style: setStyleForState( style, selectedState, newStyle ),
- } );
- }
- : ( newStyle ) => {
- setAttributes( styleToAttributes( newStyle ) );
- };
+ }, [ style, textColor, backgroundColor, gradient ] );
- const Wrapper = asWrapper || ColorInspectorControl;
+ const onChange = ( newStyle ) => {
+ setAttributes( styleToAttributes( newStyle ) );
+ };
if ( ! isEnabled ) {
return null;
@@ -338,19 +335,27 @@ export function ColorEdit( {
'__experimentalDefaultControls',
] );
+ const contrastCheckerBlockSupportEnabled =
+ false !==
+ getBlockSupport( name, [ COLOR_SUPPORT_KEY, 'enableContrastChecker' ] );
+
+ // Determine whether to show contrast checking at all.
const enableContrastChecking =
- ! isStateSelected &&
Platform.OS === 'web' &&
! value?.color?.gradient &&
( settings?.color?.text || settings?.color?.link ) &&
- // Contrast checking is enabled by default.
- // Deactivating it requires `enableContrastChecker` to have
- // an explicit value of `false`.
- false !==
- getBlockSupport( name, [
- COLOR_SUPPORT_KEY,
- 'enableContrastChecker',
- ] );
+ contrastCheckerBlockSupportEnabled;
+
+ /**
+ * Whether the user is currently viewing a pseudo-state panel (e.g. ':hover')
+ * rather than the default state. The sentinel string 'default' is treated as
+ * no active state to keep the API simple for callers.
+ */
+ const isEditingPseudoState =
+ activePseudoState && activePseudoState !== 'default';
+
+ // Use provided wrapper or default to ColorInspectorControl.
+ const Wrapper = asWrapper || ColorInspectorControl;
return (
{ enableContrastChecking && (
-
+ <>
+ { /*
+ * For pseudo-state panels, use the attribute-based checker.
+ * For the default-state panel (or no active state), use the
+ * existing DOM-based checker.
+ *
+ * This also fixes the "false hover warning" described in #78305:
+ * when the user is on the default panel and physically hovers the
+ * canvas element, the DOM checker would previously read hover-state
+ * computed styles and emit spurious warnings. By gating the
+ * DOM-based checker on `!isEditingPseudoState` and having it only
+ * fire when no pseudo-state is active, we guarantee it always sees
+ * the resting-state colors.
+ */ }
+ { isEditingPseudoState ? (
+
+ ) : (
+
+ ) }
+ >
) }
);
diff --git a/packages/block-editor/src/hooks/pseudo-state-contrast-checker.js b/packages/block-editor/src/hooks/pseudo-state-contrast-checker.js
new file mode 100644
index 00000000000000..28d8c984c9a095
--- /dev/null
+++ b/packages/block-editor/src/hooks/pseudo-state-contrast-checker.js
@@ -0,0 +1,232 @@
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+import { useMemo, Platform } from '@wordpress/element';
+import { getBlockSupport } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import ContrastChecker from '../components/contrast-checker';
+import { COLOR_SUPPORT_KEY } from './color';
+import { store as blockEditorStore } from '../store';
+import { getColorObjectByAttributeValues } from '../components/colors';
+import { useSettings } from '../components/use-settings';
+
+/**
+ * Resolves a CSS custom property like `var:preset|color|slug` or a raw hex
+ * value to a concrete hex string that `ContrastChecker` can consume.
+ *
+ * @param {string|undefined} value Raw value from theme.json style object.
+ * @param {Array} colors Merged palette (user → theme → default).
+ * @return {string|undefined} Resolved hex color, or `undefined`.
+ */
+function resolveColorValue( value, colors ) {
+ if ( ! value ) {
+ return undefined;
+ }
+ // Preset variable format: 'var:preset|color|'
+ if ( value.startsWith( 'var:preset|color|' ) ) {
+ const slug = value.slice( 'var:preset|color|'.length );
+ return getColorObjectByAttributeValues( colors, slug )?.color;
+ }
+ // Plain hex / rgb / named color — pass through as-is.
+ return value;
+}
+
+/**
+ * Merge a pseudo-state color object with the default state's color object so
+ * that any property the pseudo-state leaves unset falls back to the default.
+ *
+ * @param {Object|undefined} pseudoStateColor e.g. style.elements.link[':hover'].color
+ * @param {Object|undefined} defaultColor e.g. style.color (the block's default)
+ * @return {{ text: string|undefined, background: string|undefined }} Merged
+ * color object with pseudo-state values overriding defaults.
+ */
+function mergeWithFallback( pseudoStateColor, defaultColor ) {
+ return {
+ text:
+ pseudoStateColor?.text !== undefined
+ ? pseudoStateColor.text
+ : defaultColor?.text,
+ background:
+ pseudoStateColor?.background !== undefined
+ ? pseudoStateColor.background
+ : defaultColor?.background,
+ };
+}
+
+/**
+ * Extracts the color object for a given pseudo-state from `style.elements`.
+ *
+ * For block-level pseudo-states such as `:hover`, `:focus`, `:focus-visible`,
+ * and `:active`, Gutenberg stores the overrides inside:
+ *
+ * style.elements.link[':hover'].color (for link elements)
+ * style[':hover'].color (for block root pseudo-states)
+ *
+ * PR #76491 introduced a unified `activePseudoState` concept that adds the
+ * state as a key directly on the block's style object. We try both locations
+ * so the checker works regardless of which nesting convention was used.
+ *
+ * @param {Object} style Full block style attribute.
+ * @param {string} pseudoState One of ':hover', ':focus', ':focus-visible', ':active'.
+ * @return {Object|undefined} Color sub-object for that state, if any.
+ */
+function getPseudoStateColor( style, pseudoState ) {
+ if ( ! style || ! pseudoState ) {
+ return undefined;
+ }
+ // Convention 1: style[':hover'].color (block-root pseudo-state)
+ const rootStateColor = style[ pseudoState ]?.color;
+ if ( rootStateColor ) {
+ return rootStateColor;
+ }
+ // Convention 2: style.elements.link[':hover'].color (link element)
+ const linkStateColor = style?.elements?.link?.[ pseudoState ]?.color;
+ if ( linkStateColor ) {
+ return linkStateColor;
+ }
+ return undefined;
+}
+
+/**
+ * `PseudoStateContrastChecker` renders a `` notice when the
+ * user is editing a pseudo-state color panel (e.g. the ":hover" state of a
+ * Button block's colors).
+ *
+ * **Problem being solved** (issue #78305):
+ *
+ * The existing `BlockColorContrastChecker` works by querying `getComputedStyle`
+ * on the live canvas DOM element. When the user is editing the *default* state
+ * this is fine — the computed styles reflect the current colors. But when
+ * editing a pseudo-state (`:hover`, `:focus`, etc.) the canvas element is in
+ * its *resting* state; the pseudo-state CSS rules are never applied unless the
+ * user physically hovers the element. So `getComputedStyle` always returns
+ * the default-state colors and the contrast check is silently skipped.
+ *
+ * **Solution**:
+ *
+ * For pseudo-state panels, read colors **directly from the block's style
+ * attribute** rather than from the DOM. Any color the pseudo-state leaves
+ * unset is inherited from the default state (also read from attributes).
+ * The resolved values are passed as props to the pure ``
+ * component which performs the WCAG 2.0 AA ratio check.
+ *
+ * **False-hover guard** (described in #78305):
+ *
+ * When the user is editing the *default* state and physically hovers the block
+ * canvas the DOM's `:hover` pseudo-class fires, which used to cause the DOM-
+ * based checker to read hover-state colors and show spurious warnings. Because
+ * this component is *only* rendered for genuine pseudo-state panels (when
+ * `activePseudoState` is set), it never runs in the default-state context,
+ * eliminating the false-positive case entirely.
+ *
+ * @param {Object} props
+ * @param {string} props.clientId Block client ID.
+ * @param {string} props.name Block name (for support checks).
+ * @param {string} props.activePseudoState One of ':hover', ':focus',
+ * ':focus-visible', ':active'.
+ * @return {Object|null} Contrast checker UI element for the active pseudo-state,
+ * or `null` when checking is not applicable.
+ */
+export default function PseudoStateContrastChecker( {
+ clientId,
+ name,
+ activePseudoState,
+} ) {
+ // Only run on web; skip if there is no active pseudo-state.
+ if ( Platform.OS !== 'web' || ! activePseudoState ) {
+ return null;
+ }
+
+ // Respect the block's `enableContrastChecker` support flag (same guard as
+ // in ColorEdit for the default-state checker).
+ const contrastCheckerEnabled =
+ false !==
+ getBlockSupport( name, [ COLOR_SUPPORT_KEY, 'enableContrastChecker' ] );
+
+ if ( ! contrastCheckerEnabled ) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+/**
+ * Inner component that hooks into the store — keeps the outer component free
+ * of conditional hook calls.
+ *
+ * @param {Object} props
+ * @param {string} props.clientId
+ * @param {string} props.activePseudoState
+ * @return {Object|null} Contrast checker notice element for resolved pseudo-state
+ * colors, or `null` when colors are unavailable.
+ */
+function PseudoStateContrastCheckerInner( { clientId, activePseudoState } ) {
+ // Load the merged color palette (user overrides > theme > defaults).
+ const [ userPalette, themePalette, defaultPalette ] = useSettings(
+ 'color.palette.custom',
+ 'color.palette.theme',
+ 'color.palette.default'
+ );
+
+ const colors = useMemo(
+ () => [
+ ...( userPalette || [] ),
+ ...( themePalette || [] ),
+ ...( defaultPalette || [] ),
+ ],
+ [ userPalette, themePalette, defaultPalette ]
+ );
+
+ // Read the full style attribute from the block.
+ const style = useSelect(
+ ( select ) => {
+ return (
+ select( blockEditorStore ).getBlockAttributes( clientId )
+ ?.style ?? {}
+ );
+ },
+ [ clientId ]
+ );
+
+ // Resolve the effective text and background colors for the active
+ // pseudo-state, falling back to the block's default-state colors.
+ const { textColor, backgroundColor } = useMemo( () => {
+ // Default-state colors (the block's own style.color).
+ const defaultColor = style?.color;
+
+ // Pseudo-state overrides (may be partial or absent).
+ const pseudoStateColor = getPseudoStateColor(
+ style,
+ activePseudoState
+ );
+
+ // Merge: pseudo-state wins for any property it defines; default fills gaps.
+ const merged = mergeWithFallback( pseudoStateColor, defaultColor );
+
+ return {
+ textColor: resolveColorValue( merged.text, colors ),
+ backgroundColor: resolveColorValue( merged.background, colors ),
+ };
+ }, [ style, activePseudoState, colors ] );
+
+ // If neither color is resolved there is nothing meaningful to check.
+ if ( ! textColor && ! backgroundColor ) {
+ return null;
+ }
+
+ return (
+
+ );
+}