Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
"php tests/unit/navigation-block-normalizer.php",
"php tests/unit/menu-chrome-presentation-carry.php",
"php tests/unit/navigation-inline-margin-carry.php",
"php tests/unit/navigation-layout-default.php",
"php tests/unit/navigation-source-provenance.php",
"php tests/unit/navigation-dropped-anchors.php",
"php tests/unit/navigation-inert-support-children.php",
Expand Down
17 changes: 15 additions & 2 deletions php-transformer/src/HtmlToBlocks/HtmlCompilation.php
Original file line number Diff line number Diff line change
Expand Up @@ -2076,6 +2076,18 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo
// became 155x82 and menu 1005 at x=265. `max-width:100%` keeps the
// block shrinkable, so a narrow viewport still hands over to core's
// responsive overlay rather than overflowing the page.
// core's navigation renderer repeats the block's class list on the
// inner container, so a single authored box is painted twice: once
// on the <nav> and again on its <ul>. Measured on busybearscleaning
// at 1440px, an authored 19.3517px padding produced a 105.78px menu
// against the source's 68.09px. The source declared that box on one
// element, so the repeated container copy is neutralized and the
// authored geometry keeps its single application.
// An authored selector can outrank any generated one, so the reset
// is declared important. The <nav> keeps the authored class list and
// therefore still paints the source box exactly once, whether the
// source declared it on the menu element or on its list.
$afterAuthorCssParts[] = '.wp-block-navigation.blocks-engine-list-navigation>.wp-block-navigation__container{padding:0!important;margin:0!important;border-width:0!important}';
$afterAuthorCssParts[] = 'nav.wp-block-group>.wp-block-navigation.blocks-engine-list-navigation{width:max-content;max-width:100%}';
foreach ( $this->navigationStyleProjector->listNavigationInlineMarginRules($serializedBlocks) as $inlineMarginRule ) {
$afterAuthorCssParts[] = $inlineMarginRule;
Expand Down Expand Up @@ -2560,7 +2572,7 @@ private function createPatternContext(bool $includeRuntimeDomTarget): PatternCon
new NavigationPatternContext(
$includeRuntimeDomTarget ? fn (DOMElement $sourceElement): bool => $this->runtimeIslands->isRuntimeDomTarget($sourceElement) : null,
fn (DOMElement $item, DOMElement $anchor): string => $this->navigationUnderlineColor($item, $anchor),
fn (DOMElement $sourceElement): string => $this->styleResolver->resolveCssVariablesInValue($this->styleResolver->specificityResolvedPresentationStyle($sourceElement)),
fn (DOMElement $sourceElement): string => $this->styleResolver->resolveCssVariablesInValue($this->styleResolver->specificityResolvedPresentationStyle($sourceElement), $sourceElement),
fn (DOMElement $sourceElement): array => $this->navigationStyleProjector->navigationColorInteractionStates($sourceElement),
fn (DOMElement $sourceElement): string => $this->navigationToggleSuppressor->navigationOverlayMenu($sourceElement),
fn (DOMElement $sourceElement): string => $this->responsiveNavigationToggleMarker($sourceElement),
Expand All @@ -2569,7 +2581,8 @@ function (DOMElement $sourceElement, array $authorClasses): void {
$this->recordInheritedNavigationPresentation($sourceElement, $authorClasses);
$this->recordNavigationContainerPaintReset($sourceElement, $authorClasses);
},
fn (DOMElement $sourceElement): array => $this->authorSemanticMarkersForElement($sourceElement)
fn (DOMElement $sourceElement): array => $this->authorSemanticMarkersForElement($sourceElement),
fn (DOMElement $sourceElement): string => $this->styleResolver->resolvedConditionalDisplay($sourceElement)
),
new MediaPatternContext(
fn (DOMElement $sourceElement): string => $this->styleResolver->mergedPresentationStyle($sourceElement),
Expand Down
19 changes: 19 additions & 0 deletions php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter
$navigationAttrs = $label instanceof DOMElement
? $this->nestedLabeledNavigationAttributes($element, $presentationAttributes)
: $this->navigationContainerAttributes($element, $presentationAttributes);
$navigationAttrs = $this->withResolvedNonFlexNavigationLayout($navigationAttrs, $element, $navigationContext);
$navigationAttrs['overlayMenu'] = $this->overlayMenu($element, $navigationContext);
if ( 'mobile' === $navigationAttrs['overlayMenu'] ) {
$navigationAttrs = $this->withClassName($navigationAttrs, 'blocks-engine-native-responsive-navigation');
Expand Down Expand Up @@ -605,6 +606,24 @@ private function withInlineNavigationDisplay(array $attrs, DOMElement $element,
return $attrs;
}

/** @param array<string, mixed> $attrs @return array<string, mixed> */
private function withResolvedNonFlexNavigationLayout(array $attrs, DOMElement $element, ?NavigationPatternContext $navigationContext): array
{
if ( null === $navigationContext || is_array($attrs['layout'] ?? null) ) {
return $attrs;
}

$display = strtolower(trim($navigationContext->resolvedDisplay($element)));

if ( '' !== $display && ! in_array($display, array( 'flex', 'inline-flex', 'grid', 'inline-grid' ), true) ) {
// core/navigation defaults to flex when layout is omitted. Core maps
// the explicit default layout to is-layout-flow at render time.
$attrs['layout'] = array( 'type' => 'default' );
}

return $attrs;
}

private function resolvedStyleDeclaresFamily(string $style, string $family): bool
{
$property = match ( $family ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ final class NavigationPatternContext
private readonly ?Closure $runtimeDomTarget;
private readonly Closure $underlineColor;
private readonly Closure $resolvedStyle;
private readonly ?Closure $resolvedDisplay;
private readonly ?Closure $colorInteractionStates;
private readonly ?Closure $overlayMenu;
private readonly ?Closure $responsiveToggleMarker;
Expand All @@ -23,6 +24,7 @@ final class NavigationPatternContext
* @param callable(DOMElement): bool|null $runtimeDomTarget
* @param callable(DOMElement, DOMElement): string $underlineColor
* @param callable(DOMElement): string $resolvedStyle
* @param callable(DOMElement): string|null $resolvedDisplay
* @param callable(DOMElement): list<string>|null $colorInteractionStates
* @param callable(DOMElement): string|null $overlayMenu
* @param callable(DOMElement): string|null $responsiveToggleMarker
Expand All @@ -39,14 +41,16 @@ public function __construct(
?callable $responsiveToggleMarker = null,
?callable $linkIconMarker = null,
?callable $inheritedPresentation = null,
?callable $labelPresentationMarkers = null
?callable $labelPresentationMarkers = null,
?callable $resolvedDisplay = null
) {
$this->linkIconMarker = null === $linkIconMarker ? null : Closure::fromCallable($linkIconMarker);
$this->inheritedPresentation = null === $inheritedPresentation ? null : Closure::fromCallable($inheritedPresentation);
$this->labelPresentationMarkers = null === $labelPresentationMarkers ? null : Closure::fromCallable($labelPresentationMarkers);
$this->runtimeDomTarget = null === $runtimeDomTarget ? null : Closure::fromCallable($runtimeDomTarget);
$this->underlineColor = Closure::fromCallable($underlineColor);
$this->resolvedStyle = Closure::fromCallable($resolvedStyle);
$this->resolvedDisplay = null === $resolvedDisplay ? null : Closure::fromCallable($resolvedDisplay);
$this->colorInteractionStates = null === $colorInteractionStates ? null : Closure::fromCallable($colorInteractionStates);
$this->overlayMenu = null === $overlayMenu ? null : Closure::fromCallable($overlayMenu);
$this->responsiveToggleMarker = null === $responsiveToggleMarker ? null : Closure::fromCallable($responsiveToggleMarker);
Expand Down Expand Up @@ -78,6 +82,11 @@ public function resolvedStyle(DOMElement $element): string
return ($this->resolvedStyle)($element);
}

public function resolvedDisplay(DOMElement $element): string
{
return null === $this->resolvedDisplay ? '' : ($this->resolvedDisplay)($element);
}

/** @return list<string> */
public function colorInteractionStates(DOMElement $element): array
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ public function applyDeclaredBlockSupport(
}
}
}
if ( 'core/navigation' === $name && is_array($fallback['spacing'] ?? null) ) {
if ( 'core/navigation' === $name
&& ! in_array('blocks-engine-list-navigation', $classes, true)
&& is_array($fallback['spacing'] ?? null) ) {
$declarations = $this->styleResolver->styleAttributeMapper()->serialize(array( 'spacing' => $fallback['spacing'] ))['style'];
foreach ( $classes as $class ) {
if ( '' !== $declarations && 'blocks-engine-list-navigation' !== $class && ! str_starts_with($class, 'blocks-engine-') ) {
Expand Down
48 changes: 46 additions & 2 deletions php-transformer/src/HtmlToBlocks/Style/StyleResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,21 @@ public function conditionalDeclaration(DOMElement $element, string $property): s
return $value;
}

/**
* Resolve a source display declaration with the conditional custom-property
* scope that a responsive capture applies to the rendered element.
*/
public function resolvedConditionalDisplay(DOMElement $element): string
{
$display = $this->conditionalDeclaration($element, 'display');
if ( '' === $display ) {
$display = (string) ($this->cssDeclarations($this->specificityResolvedPresentationStyle($element))['display'] ?? '');
}

$display = trim(preg_replace('/\s*!important\s*$/i', '', $display) ?? $display);
return $this->expandCssVariableReferences($display, $this->conditionalCascadedCustomProperties($element));
}

/**
* Value a rule states for this element that the matcher cannot evaluate.
*
Expand Down Expand Up @@ -2443,8 +2458,13 @@ function (string $prelude, string $body, array $conditions) use (&$analysis, &$i
'mediaTextSpecificity' => $this->mediaTextSelectorSpecificity($selector),
);
}
if (! $this->selectorCarriesPseudoState($selector) && array() !== $conditions && array() !== $declarations) {
$analysis['conditional'][] = array('selector' => $selector, 'declarations' => $declarations, 'conditions' => $conditions);
if (! $this->selectorCarriesPseudoState($selector) && array() !== $conditions && (array() !== $declarations || array() !== $cascadedValueDeclarations)) {
$analysis['conditional'][] = array(
'selector' => $selector,
'declarations' => $declarations,
'cascadedDeclarations' => $cascadedValueDeclarations,
'conditions' => $conditions,
);
}
if ($supportedRestingSelector) {
foreach ($imageEntries as $entry) {
Expand Down Expand Up @@ -3146,6 +3166,30 @@ public function cascadedCustomProperties(DOMElement $element): array
return $customProperties;
}

/** @return array<string, string> */
private function conditionalCascadedCustomProperties(DOMElement $element): array
{
$customProperties = $this->cascadedCustomProperties($element);
$ancestors = array();
for ( $current = $element; $current instanceof DOMElement; $current = $current->parentNode instanceof DOMElement ? $current->parentNode : null ) {
$ancestors[] = $current;
}
foreach ( array_reverse($ancestors) as $ancestor ) {
foreach ( $this->styleRuleCandidates($ancestor, 'conditional') as $rule ) {
if ( ! $this->matchesCssSelector($ancestor, (string) ($rule['selector'] ?? '')) ) {
continue;
}
foreach ( $rule['cascadedDeclarations'] ?? array() as $name => $value ) {
if ( str_starts_with((string) $name, '--') ) {
$customProperties[(string) $name] = (string) $value;
}
}
}
}

return $customProperties;
}

/**
* The element's own resolved value for one paint property — from matched
* CSS or inline style directly on `$element`, with any `var()` reference
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -766,15 +766,25 @@ private function hasEquivalentSourceNavigationVariant(DOMElement $navigation): b
}

$navigationRoot = $this->navigationLandmarkAncestor($navigation) ?? $navigation;
// A captured details disclosure owns its summary and panel navigation.
// It remains an independent mobile surface, rather than evidence that a
// sibling desktop navigation should be replaced by Core's overlay toggle.
if ( $this->isInsideCapturedDisclosure($navigationRoot) ) {
return false;
}
$signature = $this->sourceNavigationSignature($navigationRoot);
if ( '' === $signature ) {
return false;
}
if ( $this->hasCapturedDisclosureNavigation($document, $signature) ) {
return false;
}

foreach ( $document->getElementsByTagName('nav') as $candidate ) {
if ( ! $candidate instanceof DOMElement
|| $candidate->isSameNode($navigationRoot)
|| $this->isProjectedNavigationSuppressed($candidate)
|| $this->isInsideCapturedDisclosure($candidate)
|| SourceDom::elementContains($navigationRoot, $candidate)
|| SourceDom::elementContains($candidate, $navigationRoot)
) {
Expand All @@ -790,6 +800,34 @@ private function hasEquivalentSourceNavigationVariant(DOMElement $navigation): b
return false;
}

private function hasCapturedDisclosureNavigation(DOMDocument $document, string $signature): bool
{
foreach ( $document->getElementsByTagName('details') as $disclosure ) {
if ( ! $disclosure instanceof DOMElement || ! $this->isCapturedDialogControl($disclosure) ) {
continue;
}

foreach ( $disclosure->getElementsByTagName('nav') as $candidate ) {
if ( $candidate instanceof DOMElement && $signature === $this->sourceNavigationSignature($candidate) ) {
return true;
}
}
}

return false;
}

private function isInsideCapturedDisclosure(DOMElement $element): bool
{
for ( $node = $element; $node instanceof DOMElement; $node = $node->parentNode ) {
if ( 'details' === strtolower($node->tagName) && $this->isCapturedDialogControl($node) ) {
return true;
}
}

return false;
}

private function hasMobileNavigationSignal(DOMElement $element): bool
{
for ( $node = $element; $node instanceof DOMElement && 'body' !== strtolower($node->tagName); $node = $node->parentNode ) {
Expand Down
23 changes: 23 additions & 0 deletions php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,29 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter
$capturedMobileMenuDisclosureMarkup = (string) ($capturedMobileMenuDisclosure['serialized_blocks'] ?? '');
$assert(str_contains($capturedMobileMenuDisclosureMarkup, '"overlayMenu":"never"') && ! str_contains($capturedMobileMenuDisclosureMarkup, 'blocks-engine-native-responsive-navigation'), 'navigation inside a captured native disclosure does not create a nested mobile overlay', $capturedMobileMenuDisclosureMarkup);

// Separate desktop and mobile source surfaces are not an overlay pair when the
// mobile menu keeps its own native details interaction. In particular, the
// desktop menu must retain its authored display at desktop widths instead of
// receiving the global native-responsive display:flex bridge.
$separateResponsiveMenuArtifact = ( new ArtifactCompiler() )->compile(
array(
'entry' => 'index.html',
'files' => array(
'index.html' => '<!doctype html><html><head><style>'
. '.data-liberation-mobile-document{display:none!important}'
. '@media(max-width:768px){.data-liberation-desktop-document{display:none!important}.data-liberation-mobile-document{display:contents!important}}'
. '@media(min-width:769px){.desktop-navbar{display:block}}'
. '</style></head><body><div class="data-liberation-desktop-document"><header><nav class="desktop-navbar"><ul><li><a href="/">Home</a></li><li><a href="/contact">Contact</a></li></ul></nav></header></div>'
. '<div class="data-liberation-mobile-document"><header><details class="dla-disclosure"><summary aria-label="Menu"><svg aria-hidden="true"></svg></summary><div class="dla-dialog" role="dialog"><nav class="mobile-navbar"><ul><li><a href="/">Home</a></li><li><a href="/contact">Contact</a></li></ul></nav></div></details></header></div></body></html>',
),
)
)->toArray();
$separateResponsiveMenuMarkup = (string) ($separateResponsiveMenuArtifact['serialized_blocks'] ?? '');
$separateResponsiveMenuCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $separateResponsiveMenuArtifact['assets'] ?? array()));
$assert(! str_contains($separateResponsiveMenuMarkup, 'blocks-engine-native-responsive-navigation') && 2 === substr_count($separateResponsiveMenuMarkup, '"overlayMenu":"never"'), 'a separately preserved disclosure menu does not promote its desktop companion to native responsive navigation', $separateResponsiveMenuMarkup);
$assert(str_contains($separateResponsiveMenuMarkup, '<!-- wp:details') && str_contains($separateResponsiveMenuMarkup, '<summary><svg aria-hidden="true">'), 'the mobile source summary remains the emitted interactive menu control', $separateResponsiveMenuMarkup);
$assert(str_contains($separateResponsiveMenuCss, '@media(max-width:768px)') && str_contains($separateResponsiveMenuCss, '@media(min-width:769px)') && ! str_contains($separateResponsiveMenuCss, 'blocks-engine-native-responsive-navigation{display:flex!important}'), 'the emitted artifact retains authored mobile and desktop menu visibility without a native host override', $separateResponsiveMenuCss);

$linkedCapturedDisclosure = ( new HtmlTransformer() )->transform('<details><summary>&nbsp;</summary><div role="dialog" data-blocks-engine-triggers="menu-trigger"><nav><a href="/about">About</a></nav></div></details>')->toArray();
$linkedCapturedDisclosureBlock = $linkedCapturedDisclosure['blocks'][0] ?? array();
$assert(str_ends_with((string) ($linkedCapturedDisclosureBlock['blockName'] ?? ''), '/captured-dialog') && array('menu-trigger') === ($linkedCapturedDisclosureBlock['attrs']['triggerIds'] ?? null), 'explicitly linked captured disclosures lower to the typed dialog block');
Expand Down
Loading
Loading