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 @@ -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",
Expand Down
26 changes: 21 additions & 5 deletions php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 ) )
) {
Expand Down Expand Up @@ -3084,7 +3098,9 @@ private function buttonDropdownItem(DOMElement $element): ?array
* list semantics or a `dropdown`-style name still express it this way:
* `<div><a>Parent</a><div><a>Child</a><a>Child</a></div></div>`. 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 `<div><div><a>One</a></div>
* <div><a>Two</a></div></div>` the wrappers are peer items of one list.
*/
private function isAnchorOnlyCluster(DOMElement $element): bool
{
Expand Down
73 changes: 73 additions & 0 deletions php-transformer/tests/unit/navigation-peer-item-wrappers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);

/**
* A list-less menu of wrapped items stays one flat list of peers.
*
* Squarespace renders its phone menu as panels ("folders") of item wrappers:
* `nav > 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 => '<div class="nav-item"><a href="' . $href . '"><div class="nav-item-content">' . $label . '</div></a></div>';
$serialized = (string) (( new HtmlTransformer() )->transform(
'<style>.nav-folder{position:absolute;top:0;left:0;width:100%;transform:translateX(100%)}.nav-folder--active{transform:translateX(0)}'
. '.nav-folder-content{display:flex;flex-direction:column}</style>'
. '<header><div class="menu"><nav class="menu-list"><div class="nav-folder nav-folder--active"><div class="nav-folder-content"><div class="nav-wrapper">'
. $item('Home', '/') . $item('Services', '/services') . $item('Careers', '/careers') . $item('Contact', '/contact')
. '</div></div></div></nav></div></header>',
array()
)->toArray()['serialized_blocks'] ?? '');

preg_match_all('/<!-- wp:navigation-(link|submenu) (\{.*?\}) (?:\/)?-->/', $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";
Loading