From 331469cb34fa6fbd118b37f46c3ecf5928cc6a48 Mon Sep 17 00:00:00 2001 From: Ellen Bauer Date: Tue, 13 Oct 2026 22:22:41 +0700 Subject: [PATCH] fix(php-transformer): keep wrapped peer menu items flat and off panel classes A list-less menu that wraps each item (`nav > div.panel > div.content > div.item > a`, as Squarespace's phone menu folders do) was misread twice: - Each item wrapper after the first was taken for the first item's anchor-only submenu cluster, so the panel became one submenu holding every other link. That reading now needs the parent's anchor to be the wrapper's own direct child, which is the documented `
Parent
Child
` shape. - The shared wrapper class carry walked past the item into the wrappers that hold every item, so the panel's classes (and its off-canvas transform) landed on each item. The walk now stops at a wrapper that also holds another item's link, the same way it stops at a list item. Refs #2726 Co-Authored-By: Claude Opus 5.5 --- php-transformer/composer.json | 1 + .../Patterns/NavigationPattern.php | 26 +++++-- .../unit/navigation-peer-item-wrappers.php | 73 +++++++++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 php-transformer/tests/unit/navigation-peer-item-wrappers.php 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";