Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/block-editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Enhancements

- Inspector controls in the standard block-supports panels (Typography, Dimensions, Border, Color, Background, Filters) now reflect the value a block inherits from Global Styles when no local override is set. Inherited controls show that value at rest (as a placeholder, preselected option, or resolved value) and mark the label with a dotted underline; setting a local override reveals a reset affordance that clears the override back to the inherited value ([#77894](https://github.com/WordPress/gutenberg/pull/77894)).
- Inherited Global Styles now resolve per-level heading element styles for the heading-family blocks (`core/heading`, `core/site-title`, `core/post-title`, `core/query-title`, `core/comments-title`, `core/term-name`, `core/site-tagline`, `core/accordion-heading`), so inspector controls reflect values set on a specific heading level (`styles.elements.h1`–`h6`) in addition to the shared `heading` element. A block rendered at level 0 (a paragraph) folds no heading element styles; `core/accordion-heading` has no level-0 state and takes its level from the parent Accordion's block context ([#80495](https://github.com/WordPress/gutenberg/pull/80495)).

### Bug Fixes

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* WordPress dependencies
*/
import { useMemo } from '@wordpress/element';
import { useContext, useMemo } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { store as blocksStore } from '@wordpress/blocks';
import { privateApis as globalStylesEnginePrivateApis } from '@wordpress/global-styles-engine';
Expand All @@ -15,6 +15,8 @@ import {
globalStylesLinksDataKey,
} from '../../store/private-keys';
import { getVariationNameFromClass } from '../../hooks/block-style-variation';
import { useBlockEditContext } from '../block-edit/context';
import BlockContext from '../block-context';
import { unlock } from '../../lock-unlock';

const { resolveStyle } = unlock( globalStylesEnginePrivateApis );
Expand Down Expand Up @@ -43,32 +45,150 @@ function useRawGlobalStyles() {
);
}

/**
* Blocks whose heading level is handed down by a parent through block context
* rather than held in their own `level` attribute, mapped to that context key
* and the level they fall back to.
*
* Accordion Heading is the only one: the parent Accordion owns the level, and
* the child's `level` attribute has no default and is absent on pattern-,
* template- and paste-inserted blocks.
*/
const CONTEXT_HEADING_LEVEL_BLOCKS = {
'core/accordion-heading': {
contextKey: 'core/accordion-heading-level',
fallbackLevel: 3,
},
};

/**
* Resolves the heading level that selects a block's `h1`-`h6` element layer.
*
* @param {string} blockName Block name.
* @param {?number} levelAttribute The block's own `level` attribute, if it has one.
* @param {?number} contextLevel Level supplied by a parent through block context.
* @return {?number} Resolved heading level, or `undefined` when the block has none.
*/
function getHeadingLevel( blockName, levelAttribute, contextLevel ) {
const contextOwned = CONTEXT_HEADING_LEVEL_BLOCKS[ blockName ];
if ( ! contextOwned ) {
return levelAttribute;
}
// Mirrors the block's own chain (Accordion Heading saves `h${ level || 3 }`),
// so `0` coerces to the fallback instead of reading as a level-0 state.
return levelAttribute || contextLevel || contextOwned.fallbackLevel;
}

/**
* Reads the heading level a parent supplies through block context.
*
* Only the blocks in `CONTEXT_HEADING_LEVEL_BLOCKS` read a key: the value
* reaches every descendant, so an ordinary Heading inside an Accordion Panel
* must not pick it up. Returns a primitive so callers can use it as a
* `useSelect` dependency.
*
* @param {?string} blockName Block name.
* @return {?number} Level from block context, or `undefined`.
*/
function useContextHeadingLevel( blockName ) {
const blockContext = useContext( BlockContext );
const contextKey = CONTEXT_HEADING_LEVEL_BLOCKS[ blockName ]?.contextKey;
return contextKey ? blockContext[ contextKey ] : undefined;
}

/**
* Maps a block to the root-level Global Styles *element* layers that paint it
* on the canvas, in addition to (or instead of) the block's own class — e.g. a
* Button renders `.wp-element-button`, a level-2 Heading renders `<h2>`.
*
* Keys are ordered low to high precedence, so a level-specific `h2` wins over
* the generic `heading`. A level of `0` (e.g. a Site or Post Title rendered as
* a paragraph) renders no heading tag, so no element layer applies.
*
* Hand-maintained by necessity: the block-to-element relationship is not
* inferable from block metadata. `supports.color.link` and friends mark blocks
* that *contain* an element, not blocks that *are* one, and only the inverse
* element-to-selector map exists (`__EXPERIMENTAL_ELEMENTS`). Standardizing it
* as a block property is tracked in
* https://github.com/WordPress/gutenberg/issues/80438.
*
* The link-bearing heading blocks get no `link` layer: their inner-link control
* reads the `inheritedValue.elements.link` passthrough, so folding `link` in
* would bleed link color into the heading's own text.
*
* @param {string} blockName Block name.
* @param {?number} headingLevel Resolved heading level, from `getHeadingLevel`.
* @return {string[]} Ordered element keys, low to high precedence.
*/
function getElementLayers( blockName, headingLevel ) {
switch ( blockName ) {
case 'core/button':
return [ 'button' ];
case 'core/accordion-heading':
case 'core/heading':
case 'core/post-title':
case 'core/site-title':
case 'core/query-title':
case 'core/comments-title':
case 'core/term-name':
case 'core/site-tagline':

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the accordion one might need a little more work, but I'll double-check tomorrow. Thanks for taking this for a spin!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got it working for the accordion heading: it needed different handling as it derives its heading level via context rather than it being set on the individual accordion heading (so that all accordion headings in the set of accordion items receive the same heading level). It's a bit hard-coded and possibly smelly, but I tried to keep it neat where I could. Let me know if you think it's too much, and we can roll back and look at it in a follow-up if need be.

I'm a little on the fence about all this hard-coding, but at least it's fairly internal for now, and we could always look at improving it in the future... in any case, here's how it's looking now, inheriting the heading element style:

image

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little on the fence about all this hard-coding, but at least it's fairly internal for now

Nice! I think it's okay. Is there an alternative in code?

My 2c is that this small fix makes headings consistent, and therefore better for the user. That to me is more important.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I think it's okay. Is there an alternative in code?
My 2c is that this small fix makes headings consistent, and therefore better for the user. That to me is more important.

Same here. I think I was just musing out loud about the idea that at some point we might codify this in block.json in some way and make it a bit more API-ey than hard-coded.

But agreed — for now I think focusing on improving the UX while keeping the code fairly self-contained is likely the right path forward.

I haven't looked at an alternative in code, mostly I wanted to confidence check that we're okay with the hard-coding. It seems we both are for now 😄

if ( headingLevel === 0 ) {
return [];
}
return headingLevel
? [ 'heading', `h${ headingLevel }` ]
: [ 'heading' ];
default:
return [];
}
}

/**
* Internal hook that derives the active block-style-variation slug from a
* block's `className` by matching registered styles via
* `getVariationNameFromClass`. Returns `null` when no registered variation
* class is present (the most common case).
* `getVariationNameFromClass`, together with the element layers that paint
* the block. Both come from a single store subscription.
*
* The lookup is scoped to a single `useSelect` subscription; the
* `@wordpress/blocks` registered-styles slice changes only when a block's
* styles are (un)registered, so this subscription is cold in steady-state
* editor use.
*
* The block's `clientId` comes from the block edit context rather than an
* argument: these panels always render inside `BlockEditContextProvider`, so
* the caller does not have to thread it through.
*
* @param {?string} blockName Block name (e.g. `core/heading`).
* @param {?string} className Space-separated class string from block attributes.
* @return {?string} Variation slug (without the `is-style-` prefix) or `null`.
* @return {{ variationName: ?string, headingLevel: ?number }} Variation slug
* (without the `is-style-` prefix) and the block's heading level, if any.
*/
function useOwnVariation( blockName, className ) {
function useVariationAndElements( blockName, className ) {
const { clientId } = useBlockEditContext();
const contextHeadingLevel = useContextHeadingLevel( blockName );
return useSelect(
( select ) => {
if ( ! blockName || ! className ) {
return null;
if ( ! blockName ) {
return { variationName: null, headingLevel: undefined };
}
const registeredStyles =
select( blocksStore ).getBlockStyles( blockName );
return getVariationNameFromClass( className, registeredStyles );
const { level } =
select( blockEditorStore ).getBlockAttributes( clientId ) || {};
// Primitives only: `useSelect` shallow-compares this object, so
// returning a fresh array here would re-render on every action.
return {
variationName: className
? getVariationNameFromClass( className, registeredStyles )
: null,
headingLevel: getHeadingLevel(
blockName,
level,
contextHeadingLevel
),
};
},
[ blockName, className ]
[ blockName, className, clientId, contextHeadingLevel ]
);
}

Expand All @@ -93,7 +213,10 @@ function useOwnVariation( blockName, className ) {
* @return {{ value: Object, sources: Object }} Merged panel-scoped payload and source map.
*/
export function useResolvedStyle( blockName, className, selectedState = null ) {
const variationName = useOwnVariation( blockName, className );
const { variationName, headingLevel } = useVariationAndElements(
blockName,
className
);
const globalStyles = useRawGlobalStyles();

return useMemo( () => {
Expand All @@ -103,8 +226,15 @@ export function useResolvedStyle( blockName, className, selectedState = null ) {
return resolveStyle( globalStyles, {
blockName,
variationName,
elements: getElementLayers( blockName, headingLevel ),
viewport: selectedState?.viewport ?? null,
pseudoState: selectedState?.pseudo ?? null,
} );
}, [ blockName, variationName, globalStyles, selectedState ] );
}, [
blockName,
variationName,
headingLevel,
globalStyles,
selectedState,
] );
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useSelect } from '@wordpress/data';
* Internal dependencies
*/
import { useResolvedStyle } from '../inherited-value-context';
import { BlockContextProvider } from '../../block-context';

import { globalStylesDataKey } from '../../../store/private-keys';

Expand Down Expand Up @@ -46,9 +47,10 @@ jest.mock( '../../../store', () => ( {
} ) );

// `inherited-value-context.js` imports `store as blocksStore` from
// `@wordpress/blocks` for `useOwnVariation`. The blocks store's transitive
// import chain fails under this file's `@wordpress/data` mock (missing
// `createSelector`), so stub the blocks module with just the shape needed.
// `@wordpress/blocks` for `useVariationAndElements`. The blocks store's
// transitive import chain fails under this file's `@wordpress/data` mock
// (missing `createSelector`), so stub the blocks module with just the shape
// needed.
jest.mock( '@wordpress/blocks', () => ( {
store: { name: 'core/blocks' },
getBlockType: ( blockName ) =>
Expand Down Expand Up @@ -83,7 +85,10 @@ describe( 'useResolvedStyle hook', () => {
// The hook issues two `useSelect` reads: the merged Global Styles
// payload (block-editor store) and the block's registered styles
// (blocks store). Feed both from one stub.
function mockStores( rawGlobalStyles, blockStyles = [] ) {
function mockStores(
rawGlobalStyles,
{ blockStyles = [], attributes = {} } = {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be later, but you do you think a test with { level: 2 } (or one with { level: 0 }) would catch a future regression in the path between useVariationAndElements and getElementLayers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've already got a level: 2 so I've added one with level: 0, good catch!

) {
useSelect.mockImplementation( ( mapSelect ) =>
mapSelect( () => ( {
// `globalStylesDataKey` holds the BARE merged styles tree
Expand All @@ -96,10 +101,84 @@ describe( 'useResolvedStyle hook', () => {
[ globalStylesDataKey ]: rawGlobalStyles,
} ),
getBlockStyles: () => blockStyles,
// The hook reads the block's `level` attribute to pick the
// heading element layers. No block edit context is provided
// here, so `clientId` is undefined; the stub returns whatever
// attributes the test supplies regardless.
getBlockAttributes: () => attributes,
} ) )
);
}

// Resolves a block's styles as rendered inside an Accordion, which supplies
// the heading level to every descendant through block context.
function resolveInAccordion( blockName, level = 3 ) {
render(
<BlockContextProvider
value={ { 'core/accordion-heading-level': level } }
>
<Probe blockName={ blockName } />
</BlockContextProvider>
);
return JSON.parse( screen.getByTestId( 'probe' ).textContent );
}

// Accordion Heading resolves its level from the parent Accordion's block
// context, not from its own `level` attribute, and has no level-0 state.
test( 'accordion-heading takes its level from block context', () => {
mockStores( {
elements: {
heading: { color: { text: '#111111' } },
h3: { typography: { fontSize: '19px' } },
},
} );
const parsed = resolveInAccordion( 'core/accordion-heading' );
expect( parsed.value.typography.fontSize ).toBe( '19px' );
expect( parsed.value.color.text ).toBe( '#111111' );
} );

test( 'accordion-heading with no context still resolves to h3', () => {
mockStores( {
elements: { h3: { typography: { fontSize: '19px' } } },
} );
render( <Probe blockName="core/accordion-heading" /> );
const parsed = JSON.parse( screen.getByTestId( 'probe' ).textContent );
expect( parsed.value.typography.fontSize ).toBe( '19px' );
} );

test( "accordion-heading's own level attribute wins over block context", () => {
mockStores(
{
elements: {
h3: { typography: { fontSize: '19px' } },
h5: { typography: { fontSize: '13px' } },
},
},
{ attributes: { level: 5 } }
);
const parsed = resolveInAccordion( 'core/accordion-heading' );
// The attribute is what the front end serializes (`save.js` renders
// `h${ level || 3 }`), so it takes precedence when the two disagree.
expect( parsed.value.typography.fontSize ).toBe( '13px' );
} );

// An ordinary Heading can be placed inside an Accordion Panel, which puts
// `core/accordion-heading-level` in its block context. Only the blocks that
// opt in may read that key.
test( 'a plain heading ignores an accordion level in block context', () => {
mockStores(
{
elements: {
h2: { typography: { fontSize: '24px' } },
h3: { typography: { fontSize: '19px' } },
},
},
{ attributes: { level: 2 } }
);
const parsed = resolveInAccordion( 'core/heading' );
expect( parsed.value.typography.fontSize ).toBe( '24px' );
} );

test( 'returns empty value and sources when no block name is given', () => {
mockStores( { typography: { fontSize: '16px' } } );
render( <Probe /> );
Expand Down Expand Up @@ -226,6 +305,7 @@ describe( 'useResolvedStyle – production bare-tree shape', () => {
[ globalStylesDataKey ]: rawGlobalStyles,
} ),
getBlockStyles: () => [],
getBlockAttributes: () => ( {} ),
} ) )
);
return render( ui );
Expand Down
2 changes: 1 addition & 1 deletion packages/global-styles-engine/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

### New Features

- Added a private `resolveStyle` API that resolves the merged Global Styles cascade (root, block type, and applied block style variation) for a block, returning the inherited value and a per-leaf source map.
- Added a private `resolveStyle` API that resolves the merged Global Styles cascade (root, root-level element layers, block type, and applied block style variation) for a block, returning the inherited value and a per-leaf source map. Element layers are supplied by the caller as an ordered `elements` list on the resolve context (low to high precedence, e.g. `[ 'heading', 'h2' ]`), keeping the engine agnostic of block-to-element mappings.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs a #80495 link I think

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the PR to a separate changelog entry. I left the existing entries alone just in case it affects branch conflicts when we go to backport this (but I might be overthinking this one 😄)

- Added a private `getVariationStyle` API that returns a block style variation's styles, resolving `{ ref }` references against the Global Styles tree by default (pass `{ resolveRefs: false }` to keep them in place).

## 1.18.0 (2026-07-14)
Expand Down
Loading
Loading