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
2 changes: 2 additions & 0 deletions php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,8 @@
"php tests/unit/navigation-peer-item-wrappers.php",
"php tests/unit/navigation-button-dropdown-submenu.php",
"php tests/unit/navigation-menu-row-beside-controls.php",
"php tests/unit/navigation-menu-dialog-fold.php",
"php tests/contract/shared-menu-with-folded-dialog.php",
"php tests/unit/paragraph-label-desktop-menu.php",
"php tests/unit/button-signal-classifier.php",
"php tests/unit/pill-button-anchor.php",
Expand Down
155 changes: 153 additions & 2 deletions php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\AuthoredButtonBlockGenerator;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\AuthorSelectorProjectionState;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\StyleAttributeMapper;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\CapturedMenuDialogFold;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\LinkUrlSanitizer;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SourceDom;
use DOMElement;
Expand Down Expand Up @@ -57,6 +58,15 @@ final class NavigationPattern implements PatternRecognizerInterface
*/
private const LIST_NAVIGATION_CLASS = 'blocks-engine-list-navigation';

/** The navigation that took over an absorbed menu dialog. */
private const MENU_FOLD_NAVIGATION_CLASS = 'blocks-engine-menu-fold-navigation';

/** A row control the absorbed menu dialog repeats; hidden where the hamburger takes over. */
private const MENU_FOLD_HIDE_CLASS = 'blocks-engine-menu-fold-hide';

/** A dialog entry carried into the overlay; hidden where the row shows in full. */
private const MENU_FOLD_OVERLAY_ONLY_CLASS = 'blocks-engine-menu-fold-overlay-only';

public function claimsBeforeAuthorOwnedLayout(DOMElement $element): bool
{
$tag = strtolower($element->tagName);
Expand Down Expand Up @@ -856,6 +866,20 @@ private function menuRowBesideControls(DOMElement $row, PatternContext $context,
return null;
}

// A captured mobile menu that repeats this row belongs to the row's
// own navigation. It stays only if the row can take it over whole.
$fold = $navigationContext?->capturedMenuFold($row);
$result = $this->menuRowSlots($row, $converter, $presentationAttributes, $innerHtml, $createBlock, $navigationContext, $fold);
if ( null === $result && $fold instanceof CapturedMenuDialogFold ) {
$navigationContext?->releaseCapturedMenuFold($fold);
return $this->menuRowSlots($row, $converter, $presentationAttributes, $innerHtml, $createBlock, $navigationContext, null);
}

return $result;
}

private function menuRowSlots(DOMElement $row, PatternTreeConverter $converter, callable $presentationAttributes, callable $innerHtml, callable $createBlock, ?NavigationPatternContext $navigationContext, ?CapturedMenuDialogFold $fold): ?PatternRecognitionResult
{
$buttonSignals = new ButtonSignalClassifier();
$slots = array();
foreach ( $row->childNodes as $child ) {
Expand All @@ -877,21 +901,37 @@ private function menuRowBesideControls(DOMElement $row, PatternContext $context,
}
}

// One run of items can take the overlay; a row split into several runs
// keeps its dialog the way it was captured.
$itemRuns = count(array_filter($slots, static fn (array $slot): bool => 'items' === $slot['kind']));
if ( $fold instanceof CapturedMenuDialogFold && 1 !== $itemRuns ) {
return null;
}

$fallbacks = array();
$children = array();
$collected = array();
$linkSets = array();
$foldMarker = '';
foreach ( $slots as $slot ) {
if ( 'control' === $slot['kind'] ) {
$block = $converter->element($slot['elements'][0], $fallbacks, true);
if ( ! is_array($block) || in_array((string) ( $block['blockName'] ?? '' ), array( '', 'core/html' ), true) ) {
return null;
}
// The dialog lists this control too, and carries its own copy
// into the overlay, so the row's copy is hidden below the
// breakpoint where the hamburger takes over.
if ( $fold instanceof CapturedMenuDialogFold && $fold->listsControl($slot['elements'][0]) ) {
$block = $this->withBlockClassName($block, self::MENU_FOLD_HIDE_CLASS, $createBlock, $slot['elements'][0]);
}
$children[] = $block;
continue;
}

$links = array();
$itemLinks = array();
$overlayExtras = array();
foreach ( $slot['elements'] as $item ) {
$link = 'a' === strtolower($item->tagName)
? $this->navigationLinkBlock($item, $presentationAttributes, $innerHtml, $createBlock, $item, $navigationContext)
Expand All @@ -900,28 +940,139 @@ private function menuRowBesideControls(DOMElement $row, PatternContext $context,
return null;
}
$links[] = $link;
if ( 'a' === strtolower($item->tagName) ) {
$itemLinks[CapturedMenuDialogFold::signature('link', CapturedMenuDialogFold::label($item), trim($item->getAttribute('href')))] = true;
}
}
$attrs = array( 'overlayMenu' => 'never' );
$gap = trim((string) ( $this->resolvedNavigationSpacing($navigationContext?->resolvedStyle($row) ?? '')['blockGap'] ?? '' ));
if ( '' !== $gap ) {
$attrs['style']['spacing']['blockGap'] = $gap;
}
if ( $fold instanceof CapturedMenuDialogFold ) {
$overlay = $this->foldedOverlayAttributes($row, $fold, $attrs, $itemLinks, $converter, $createBlock, $navigationContext, $fallbacks);
if ( null === $overlay ) {
return null;
}
[ $attrs, $overlayExtras, $foldMarker ] = $overlay;
}
// Shared link text settings are read from the menu's own items; the
// overlay-only extras are not items and must not dilute them.
$attrs = array_replace_recursive($attrs, $this->commonNavigationLinkTextAttributes($links));
$linkSets[] = $links;
$children[] = $createBlock('core/navigation', $attrs, $links, $slot['elements'][0]);
$children[] = $createBlock('core/navigation', $attrs, array_merge($links, $overlayExtras), $slot['elements'][0]);
}
if ( array() === $linkSets ) {
return null;
}

$this->recordNavigationSources(array(), array(), $collected, $row, $navigationContext);

$groupAttrs = array_merge($presentationAttributes($row), array( 'tagName' => 'div' ));
if ( '' !== $foldMarker ) {
$groupAttrs = $this->withClassName($groupAttrs, $foldMarker);
}

return new PatternRecognitionResult(
$createBlock('core/group', array_merge($presentationAttributes($row), array( 'tagName' => 'div' )), $children, $row),
$createBlock('core/group', $groupAttrs, $children, $row),
$fallbacks
);
}

/**
* Turn the row's single navigation into the responsive one that replaces
* the hamburger and the dialog.
*
* The navigation takes core's overlay and the hamburger's look. Dialog
* entries the row shows outside its items (a call-to-action link, say) go
* into the overlay as well, hidden once the row is back to its wide layout.
* Below the breakpoint the row is shown again, with the controls the dialog
* lists hidden, so the page keeps one hamburger and one copy of each
* control. Returns null when an overlay entry cannot be converted.
*
* @param array<string, mixed> $attrs
* @param array<string, true> $itemLinks
* @param array<int, array<string, mixed>> $fallbacks
* @return array{0: array<string, mixed>, 1: array<int, array<string, mixed>>, 2: string}|null The attributes, the overlay-only blocks, and the row marker.
*/
private function foldedOverlayAttributes(DOMElement $row, CapturedMenuDialogFold $fold, array $attrs, array $itemLinks, PatternTreeConverter $converter, callable $createBlock, ?NavigationPatternContext $navigationContext, array &$fallbacks): ?array
{
if ( null === $navigationContext ) {
return null;
}

$extras = array();
foreach ( $fold->dialog->getElementsByTagName('a') as $anchor ) {
if ( ! $anchor instanceof DOMElement
|| isset($itemLinks[CapturedMenuDialogFold::signature('link', CapturedMenuDialogFold::label($anchor), trim($anchor->getAttribute('href')))])
) {
continue;
}
$block = $converter->element($anchor, $fallbacks, true);
if ( ! is_array($block) || in_array((string) ( $block['blockName'] ?? '' ), array( '', 'core/html' ), true) ) {
return null;
}
$extras[] = $this->withBlockClassName($block, self::MENU_FOLD_OVERLAY_ONLY_CLASS, $createBlock, $anchor);
}

$attrs['overlayMenu'] = 'mobile';
// The source's dropdown buttons and its mobile accordions both open on
// click; Core keeps overlay submenus collapsed until then only in click mode.
$attrs['openSubmenusOnClick'] = true;
$attrs = $this->withClassName($attrs, 'blocks-engine-native-responsive-navigation ' . self::MENU_FOLD_NAVIGATION_CLASS);
$attrs = $this->withResponsiveToggleMarker($attrs, $row, $navigationContext);
// The open overlay is the dialog's panel, so its paint comes from the dialog.
$attrs = $this->withResponsiveOverlayMarker($attrs, $fold->dialog, $navigationContext);

$boundary = $navigationContext->menuCollapseBreakpoint($row, $fold->triggers[0]);
$below = null === $boundary || $boundary <= 600 ? 599 : (int) floor($boundary);
$display = 'flex';
if ( 1 === preg_match('/(?:^|;)\s*display\s*:\s*(flex|inline-flex|grid|block)\b/i', $navigationContext->resolvedStyle($row), $match) ) {
$display = strtolower($match[1]);
}
$hostRule = '.wp-block-group.%s{display:' . $display . '!important}'
. '.wp-block-group.%s>.' . self::MENU_FOLD_HIDE_CLASS . '{display:none!important}';
$groupGap = $fold->group instanceof DOMElement
? trim((string) ( $this->resolvedNavigationSpacing($navigationContext->resolvedStyle($fold->group))['blockGap'] ?? '' ))
: '';
if ( '' !== $groupGap ) {
$hostRule .= '.wp-block-group.%s{gap:' . $groupGap . '!important}';
}
if ( $fold->hamburgerLast ) {
$hostRule .= '.wp-block-group.%s>.wp-block-navigation{order:99}';
}
$marker = 'blocks-engine-menu-fold-' . substr(hash('sha256', $below . $display . $groupGap . ( $fold->hamburgerLast ? 'last' : '' )), 0, 12);
$host = '.wp-block-navigation.' . self::MENU_FOLD_NAVIGATION_CLASS;
$rule = '@media(max-width:' . $below . 'px){' . str_replace('%s', $marker, $hostRule) . '}'
. '@media(min-width:' . ( $below + 1 ) . 'px){.wp-block-group.' . $marker . ' .' . self::MENU_FOLD_OVERLAY_ONLY_CLASS . '{display:none!important}}'
// The engine's list-navigation repairs lay items out as list items.
// These items are the row's own dropdown entries, which Core already
// lays out as flex items, so keep that.
. $host . ' .wp-block-navigation-item{display:flex}'
. $host . ' .wp-block-navigation-item__content{display:block}'
// A header that blurs what is behind it becomes the containing block
// for fixed descendants. While the overlay is open the header gives
// that up so the overlay covers the screen the way Core draws it.
. ':is(nav,header,div,section):has(' . $host . ' .wp-block-navigation__responsive-container.is-menu-open){backdrop-filter:none!important;-webkit-backdrop-filter:none!important;filter:none!important}';
$navigationContext->registerMenuFoldRule($marker, $rule);

return array( $attrs, $extras, $marker );
}

/**
* Add a class to a block that is already built. The block is created again
* from its own attributes, so the saved markup carries the class too.
*
* @param array<string, mixed> $block
* @return array<string, mixed>
*/
private function withBlockClassName(array $block, string $className, callable $createBlock, DOMElement $source): array
{
$attrs = $this->withClassName(is_array($block['attrs'] ?? null) ? $block['attrs'] : array(), $className);

return $createBlock((string) $block['blockName'], $attrs, is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array(), $source);
}

/**
* Per-link colour support is retained in block attributes for diagnostics,
* but core/navigation-link does not render it. Add a deterministic class to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueInspector;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\NavigationStyleProjector;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\StyleResolver;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\CapturedMenuDialogFold;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\NavigationToggleSuppressor;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SourceDom;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SvgMaterializer;
Expand Down Expand Up @@ -499,6 +500,27 @@ public function overlayMenu(DOMElement $element): string
return $this->navigationToggleSuppressor?->navigationOverlayMenu($element) ?? 'never';
}

public function capturedMenuFold(DOMElement $row): ?CapturedMenuDialogFold
{
return $this->navigationToggleSuppressor?->capturedMenuFoldForRow($row);
}

public function releaseCapturedMenuFold(CapturedMenuDialogFold $fold): void
{
$this->navigationToggleSuppressor?->releaseCapturedMenuFold($fold);
}

/** The width (px) at and below which the source shows the hamburger instead of the row. */
public function menuCollapseBreakpoint(DOMElement $row, DOMElement $trigger): ?float
{
return $this->navigationToggleSuppressor?->sourceCollapseBreakpoint($row, $trigger);
}

public function registerMenuFoldRule(string $marker, string $rule): void
{
$this->session?->generatedSupportStylesheetState()->registerNativeNavigationOverlay($marker, $rule);
}

public function responsiveToggleMarker(DOMElement $element): string
{
return $this->projectedNavigation?->responsiveNavigationToggleMarker($element) ?? '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Session;

use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\CapturedMenuDialogFold;
use DOMElement;

/**
Expand All @@ -28,6 +29,12 @@ final class NavigationProjectionState
/** @var array<string, true> */
private array $implicitDialogControlPaths = array();

/** @var array<string, CapturedMenuDialogFold> menu row path => the captured menu dialog it absorbs */
private array $foldsByRowPath = array();

/** @var array<string, CapturedMenuDialogFold> hamburger path => the fold it opens */
private array $foldsByTriggerPath = array();

public function hasProjection(): bool
{
return array() !== $this->targetsByControlPath;
Expand Down Expand Up @@ -67,4 +74,37 @@ public function isImplicitDialogControl(DOMElement $control): bool
{
return isset($this->implicitDialogControlPaths[$control->getNodePath()]);
}

public function foldMenuDialog(CapturedMenuDialogFold $fold): void
{
$this->foldsByRowPath[$fold->row->getNodePath()] = $fold;
foreach ( $fold->triggers as $trigger ) {
$this->foldsByTriggerPath[$trigger->getNodePath()] = $fold;
}
foreach ( $fold->suppressed as $element ) {
$this->suppress($element);
}
}

public function foldForRow(DOMElement $row): ?CapturedMenuDialogFold
{
return $this->foldsByRowPath[$row->getNodePath()] ?? null;
}

public function foldForTrigger(DOMElement $trigger): ?CapturedMenuDialogFold
{
return $this->foldsByTriggerPath[$trigger->getNodePath()] ?? null;
}

/** Give the dialog and hamburger back to the output when the row could not absorb them. */
public function releaseFold(CapturedMenuDialogFold $fold): void
{
unset($this->foldsByRowPath[$fold->row->getNodePath()]);
foreach ( $fold->triggers as $trigger ) {
unset($this->foldsByTriggerPath[$trigger->getNodePath()]);
}
foreach ( $fold->suppressed as $element ) {
unset($this->suppressedPaths[$element->getNodePath()]);
}
}
}
Loading
Loading