diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 49d97cf82..e38553efd 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -365,6 +365,7 @@ "php tests/unit/layout-boundary-excludes-shell.php", "php tests/unit/navigation-nested-authored-anchor.php", "php tests/unit/navigation-anchor-cluster-submenu.php", + "php tests/unit/navigation-peer-item-wrappers.php", "php tests/unit/navigation-button-dropdown-submenu.php", "php tests/unit/paragraph-label-desktop-menu.php", "php tests/unit/button-signal-classifier.php", diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index 7738fe6a8..f27e12853 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -2426,7 +2426,9 @@ private function replacedAncestorWrapperClassNames(DOMElement $anchor, DOMElemen $depth = 0; while ( $node instanceof DOMElement && ! $node->isSameNode($navigation) && $depth < 6 ) { ++$depth; - if ( 'li' === strtolower($node->tagName) ) { + // A wrapper that also holds another item's link is the list (or a + // panel of it), not a wrapper core collapsed between item and link. + if ( 'li' === strtolower($node->tagName) || $this->holdsAnotherLabelledAnchor($node, $anchor) ) { break; } foreach ( preg_split('/\s+/', trim($this->attr($node, 'class'))) ?: array() as $candidate ) { @@ -2440,6 +2442,17 @@ private function replacedAncestorWrapperClassNames(DOMElement $anchor, DOMElemen return $classes; } + private function holdsAnotherLabelledAnchor(DOMElement $wrapper, DOMElement $anchor): bool + { + foreach ( $wrapper->getElementsByTagName('a') as $candidate ) { + if ( ! $candidate->isSameNode($anchor) && '' !== trim($candidate->textContent ?? '') ) { + return true; + } + } + + return false; + } + /** * A single class token safe to carry from a replaced wrapper onto the * generated navigation item. HTML class tokens are whitespace-delimited; @@ -2873,8 +2886,9 @@ private function primaryNavigationAnchor(DOMElement $element): ?DOMElement } // A cluster of child links that follows the item's own anchor is its - // submenu, not a second anchor carrier. - if ( array() !== $anchors && $this->isAnchorOnlyCluster($child) ) { + // submenu, not a second anchor carrier. An anchor found inside a + // sibling wrapper is a peer item's, so the next wrapper is a peer too. + if ( array() !== $anchors && $element->isSameNode($anchors[0]->parentNode) && $this->isAnchorOnlyCluster($child) ) { continue; } @@ -2943,7 +2957,7 @@ private function collectSubmenuContainers(DOMElement $element, DOMElement $prima $tagName = strtolower($child->tagName); if ( in_array($tagName, array( 'nav', 'ul', 'ol' ), true) || $this->hasSubmenuSignal($child) - || $this->isAnchorOnlyCluster($child) + || ( $element->isSameNode($primaryAnchor->parentNode) && $this->isAnchorOnlyCluster($child) ) || ( $this->isNavigationWrapperElement($child) && ( 0 < $child->getElementsByTagName('ul')->length || 0 < $child->getElementsByTagName('ol')->length ) ) ) { @@ -3084,7 +3098,9 @@ private function buttonDropdownItem(DOMElement $element): ?array * list semantics or a `dropdown`-style name still express it this way: * `
Parent
ChildChild
`. Structure * alone carries the parent/child relationship, so no class vocabulary is - * needed to read the wrapper as the item's submenu. + * needed to read the wrapper as the item's submenu. Only an anchor that is + * the wrapper's own direct child owns it: in `
One
+ *
Two
` the wrappers are peer items of one list. */ private function isAnchorOnlyCluster(DOMElement $element): bool { diff --git a/php-transformer/tests/unit/navigation-peer-item-wrappers.php b/php-transformer/tests/unit/navigation-peer-item-wrappers.php new file mode 100644 index 000000000..63f53db32 --- /dev/null +++ b/php-transformer/tests/unit/navigation-peer-item-wrappers.php @@ -0,0 +1,73 @@ + div.folder > div.content > div.item > a`. Two readings broke it: + * - each `div.item` after the first was read as the first item's submenu + * cluster, so the whole panel became one "Home" submenu with every other + * link hidden inside it; + * - the classes of the panel and its content wrapper, which hold every item, + * were carried onto each item as if core had collapsed them between item + * and link. The panel's off-canvas rule (`translateX(100%)`) then moved + * every item off screen. + */ + +require dirname(__DIR__, 2) . '/vendor/autoload.php'; + +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer; + +$failures = 0; +$passes = 0; +$assert = static function (bool $ok, string $message, string $detail = '') use (&$failures, &$passes): void { + if ( $ok ) { + ++$passes; + return; + } + ++$failures; + fwrite(STDERR, 'FAIL: ' . $message . ( '' !== $detail ? ' - ' . $detail : '' ) . PHP_EOL); +}; + +$item = static fn (string $label, string $href): string => ''; +$serialized = (string) (( new HtmlTransformer() )->transform( + '' + . '
', + array() +)->toArray()['serialized_blocks'] ?? ''); + +preg_match_all('//', $serialized, $matches, PREG_SET_ORDER); +$labels = array(); +$classes = array(); +foreach ( $matches as $match ) { + $attrs = json_decode($match[2], true); + $labels[] = $match[1] . ':' . ($attrs['label'] ?? ''); + $classes[] = (string) ($attrs['className'] ?? ''); +} + +$assert( + array( 'link:Home', 'link:Services', 'link:Careers', 'link:Contact' ) === $labels, + 'every wrapped item is a peer link, with no submenu', + json_encode($labels) +); +$assert( + array() !== $classes && array() === array_filter($classes, static fn (string $class): bool => 1 === preg_match('/(?:^|\s)(?:nav-folder|nav-folder--active|nav-folder-content|nav-wrapper)(?:\s|$)/', $class)), + 'classes of the wrappers that hold every item stay off the items', + json_encode($classes) +); +$assert( + array() !== $classes && array() === array_filter($classes, static fn (string $class): bool => 1 !== preg_match('/(?:^|\s)nav-item(?:\s|$)/', $class)), + 'the per-item wrapper core collapses still carries its class', + json_encode($classes) +); + +if ( 0 < $failures ) { + fwrite(STDERR, "Navigation peer item wrappers: {$failures} failed, {$passes} passed\n"); + exit(1); +} + +echo "Navigation peer item wrappers passed: {$passes} assertions\n";