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 @@ -377,6 +377,8 @@
"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-controlled-dropdown-item.php",
"php tests/unit/navigation-owned-submenu-panel.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",
Expand Down
127 changes: 124 additions & 3 deletions php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
Original file line number Diff line number Diff line change
Expand Up @@ -3166,7 +3166,7 @@ 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. 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) ) {
if ( array() !== $anchors && $this->ownsSubmenuCluster($element, $anchors[0], $child) ) {
continue;
}

Expand Down Expand Up @@ -3235,7 +3235,7 @@ private function collectSubmenuContainers(DOMElement $element, DOMElement $prima
$tagName = strtolower($child->tagName);
if ( in_array($tagName, array( 'nav', 'ul', 'ol' ), true)
|| $this->hasSubmenuSignal($child)
|| ( $element->isSameNode($primaryAnchor->parentNode) && $this->isAnchorOnlyCluster($child) )
|| $this->ownsSubmenuCluster($element, $primaryAnchor, $child)
|| ( $this->isNavigationWrapperElement($child)
&& ( 0 < $child->getElementsByTagName('ul')->length || 0 < $child->getElementsByTagName('ol')->length ) )
) {
Expand Down Expand Up @@ -3367,7 +3367,128 @@ private function hasButtonDropdownChild(DOMElement $element): bool
/** @return array{label:string, cluster:DOMElement, button:DOMElement}|null */
private function buttonDropdownItem(DOMElement $element): ?array
{
return self::buttonDropdownItemParts($element);
return self::buttonDropdownItemParts($element) ?? $this->controlledDropdownItemParts($element);
}

/**
* An item made of a labelled button and the sibling panel it names with
* `aria-controls`, the panel holding nothing but links, each bare or in its
* own wrapper: `<div><button aria-controls="p">About</button><div id="p">
* <div><a>Mission</a></div><div><a>Team</a></div></div></div>`. The
* button's own declaration ties the panel to it, so no class vocabulary is
* needed to read the panel as the button's submenu.
*
* @return array{label:string, cluster:DOMElement, button:DOMElement}|null
*/
private function controlledDropdownItemParts(DOMElement $element): ?array
{
$children = array();
foreach ( $element->childNodes as $child ) {
if ( XML_COMMENT_NODE === $child->nodeType || ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) ) {
continue;
}
if ( ! $child instanceof DOMElement ) {
return null;
}
$children[] = $child;
}
if ( 2 !== count($children) || 'button' !== strtolower($children[0]->tagName) ) {
return null;
}
[ $button, $panel ] = $children;
$controls = trim($button->getAttribute('aria-controls'));
$label = trim(preg_replace('/\s+/', ' ', $this->visibleText($button)) ?? '');
if ( '' === $controls || $controls !== trim($panel->getAttribute('id')) || '' === $label ) {
return null;
}
return $this->isWrappedLinkPanel($panel) ? array( 'label' => $label, 'cluster' => $panel, 'button' => $button ) : null;
}

/**
* Whether `$cluster` is the submenu of the item whose own link is
* `$anchor`. The item must own that link: as its direct child, or as the
* only content of a label wrapper
* (`<div><span><a>Parent</a></span><div><a>Child</a></div></div>`). A
* wrapped label only owns the cluster when the item holds nothing else,
* the label is not a heading (a footer column's linked heading titles
* its links, it does not open them), and the two do not share a tag or a
* class: wrappers alike in either way are peer items of one list. The
* cluster holds only links, bare or one per wrapper.
*/
private function ownsSubmenuCluster(DOMElement $element, DOMElement $anchor, DOMElement $cluster): bool
{
$carrier = $anchor->parentNode;
if ( ! $carrier instanceof DOMElement ) {
return false;
}
if ( ! $element->isSameNode($carrier) ) {
$parent = $carrier->parentNode;
$sole = $this->soleMeaningfulChild($carrier);
if ( ! $parent instanceof DOMElement || ! $element->isSameNode($parent)
|| ! $sole instanceof DOMElement || ! $anchor->isSameNode($sole)
|| 2 !== $element->childElementCount
|| 1 === preg_match('/^h[1-6]$/i', $carrier->tagName)
|| strtolower($carrier->tagName) === strtolower($cluster->tagName)
|| array() !== array_intersect(
SourceDom::boundedClassTokens($carrier->getAttribute('class')),
SourceDom::boundedClassTokens($cluster->getAttribute('class'))
)
) {
return false;
}
}

return $this->isAnchorOnlyCluster($cluster) || $this->isWrappedLinkPanel($cluster);
}

/**
* The text a control paints: screen-reader-only (`sr-only`,
* `visually-hidden`, `screen-reader-text`) and `hidden` descendants are
* left out, so a visible label does not repeat assistive text.
*/
private function visibleText(DOMElement $element): string
{
$text = '';
foreach ( $element->childNodes as $child ) {
if ( XML_TEXT_NODE === $child->nodeType ) {
$text .= $child->textContent;
} elseif ( $child instanceof DOMElement && ! $child->hasAttribute('hidden')
&& 1 !== preg_match('/(?:^|[\s_-])(?:visually-hidden|sr-only|screen-reader-only|screen-reader-text)(?:$|[\s_-])/', strtolower($child->getAttribute('class')))
) {
$text .= ' ' . $this->visibleText($child) . ' ';
}
}

return $text;
}

/**
* A panel whose entries are all destination links, each bare or the only
* child of its own wrapper (`<div><div><a>One</a></div><div><a>Two</a></div></div>`).
*/
private function isWrappedLinkPanel(DOMElement $panel): bool
{
if ( ! in_array(strtolower($panel->tagName), array( 'div', 'span', 'ul', 'ol' ), true) ) {
return false;
}
$links = 0;
foreach ( $panel->childNodes as $item ) {
if ( XML_COMMENT_NODE === $item->nodeType || ( XML_TEXT_NODE === $item->nodeType && '' === trim($item->textContent ?? '') ) ) {
continue;
}
$link = $item instanceof DOMElement && in_array(strtolower($item->tagName), array( 'div', 'span', 'li' ), true)
? $this->soleMeaningfulChild($item)
: $item;
if ( ! $link instanceof DOMElement || 'a' !== strtolower($link->tagName)
|| '' === trim($link->textContent ?? '') || '' === trim($link->getAttribute('href'))
|| $link->hasAttribute('aria-controls') || $link->hasAttribute('aria-expanded')
) {
return false;
}
++$links;
}

return 0 < $links;
}

/**
Expand Down
74 changes: 74 additions & 0 deletions php-transformer/tests/unit/navigation-controlled-dropdown-item.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);

/**
* A button that names its sibling link panel with `aria-controls` is a
* dropdown item.
*
* Squarespace's desktop menu writes a folder as `div.item > button
* [aria-controls=p] + div#p > div > a ...`. Without this reading the folder's
* own label was lost: before #2743 the first child link became the submenu
* label, and after it the child links were flattened into the top row.
*/

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);
};

$items = static function (string $controls, string $panelId): array {
$child = static fn (string $label, string $href): string => '<div class="folder-item"><a href="' . $href . '">' . $label . '</a></div>';
$html = '<header><nav class="nav-list">'
. '<div class="nav-item"><a href="/">Home</a></div>'
. '<div class="nav-item nav-item--folder"><button class="folder-title" aria-expanded="false"' . $controls . '><span>About Us</span></button>'
. '<div class="folder-content"' . $panelId . '>' . $child('Our Mission', '/mission') . $child('Our Team', '/team') . '</div></div>'
. '<div class="nav-item"><a href="/services">Services</a></div>'
. '</nav></header>';
$serialized = (string) (( new HtmlTransformer() )->transform($html, array())->toArray()['serialized_blocks'] ?? '');
preg_match_all('/<!-- (\/?)wp:navigation-(link|submenu)(?: (\{.*?\}))?/', $serialized, $matches, PREG_SET_ORDER);
$out = array();
foreach ( $matches as $match ) {
if ( '/' === $match[1] ) {
$out[] = 'end';
continue;
}
$attrs = json_decode($match[3] ?? '{}', true);
$out[] = $match[2] . ':' . strip_tags((string) ($attrs['label'] ?? ''));
}

return $out;
};

$controlled = $items(' aria-controls="about-us"', ' id="about-us"');
$assert(
array( 'link:Home', 'submenu:About Us', 'link:Our Mission', 'link:Our Team', 'end', 'link:Services' ) === $controlled,
'the controlled panel becomes the button\'s submenu under the button label',
json_encode($controlled)
);

$unbound = $items(' aria-controls="elsewhere"', ' id="about-us"');
$assert(! in_array('submenu:About Us', $unbound, true), 'a button that names another element does not claim the panel', json_encode($unbound));

$hiddenLabel = (string) (( new HtmlTransformer() )->transform(
'<nav class="nav-list"><div class="nav-item"><a href="/">Home</a></div><div class="nav-item"><button aria-controls="p" aria-expanded="false">'
. '<span class="sr-only">Open submenu for </span>About</button><div id="p"><div><a href="/m">Mission</a></div></div></div></nav>',
array()
)->toArray()['serialized_blocks'] ?? '');
$assert(str_contains($hiddenLabel, '"label":"About"'), 'the submenu label leaves out screen-reader-only text', $hiddenLabel);

if ( 0 < $failures ) {
fwrite(STDERR, "Navigation controlled dropdown item: {$failures} failed, {$passes} passed\n");
exit(1);
}

echo "Navigation controlled dropdown item passed: {$passes} assertions\n";
105 changes: 105 additions & 0 deletions php-transformer/tests/unit/navigation-owned-submenu-panel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);

/**
* An item's own link owns the link panel beside it.
*
* #2743 stopped reading peer item wrappers as a submenu and stopped carrying
* list-level wrapper classes onto items. Two submenu shapes then fell through:
* - a Squarespace desktop folder titled by a link,
* `div.item > a.title + div.panel > div.entry > a`: the panel's entries
* are wrapped one link per wrapper, which the anchor-only cluster reading
* did not accept, so they came out as top-level links in the bar and the
* title lost the item's classes;
* - a parent link inside a label wrapper,
* `div.entry > span > a + div > a`: the guard wanted the link as the item's
* direct child, so the whole menu was dropped.
* Peers stay peers: a wrapped label only owns the cluster when the item
* holds nothing else, the label is not a heading, and the two share neither
* a tag nor a class.
*/

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);
};

$items = static function (string $html): array {
$serialized = (string) (( new HtmlTransformer() )->transform($html, array())->toArray()['serialized_blocks'] ?? '');
preg_match_all('/<!-- (\/?)wp:navigation-(link|submenu)(?: (\{.*?\}))?/', $serialized, $matches, PREG_SET_ORDER);
$out = array();
foreach ( $matches as $match ) {
if ( '/' === $match[1] ) {
$out[] = 'end';
continue;
}
$attrs = json_decode($match[3] ?? '{}', true);
$out[] = $match[2] . ':' . strip_tags((string) ($attrs['label'] ?? '')) . '|' . (string) ($attrs['className'] ?? '');
}

return $out;
};
$labels = static fn (array $items): array => array_map(static fn (string $item): string => strtok($item, '|'), $items);

$entry = static fn (string $label, string $href): string => '<div class="nav-folder-item"><a href="' . $href . '"><span>' . $label . '</span></a></div>';
$folder = $items(
'<header><nav class="nav-list">'
. '<div class="nav-item"><a href="/">Home</a></div>'
. '<div class="nav-item nav-item--folder"><a class="nav-folder-title" href="/services">Services</a>'
. '<div class="nav-folder-content">' . $entry('Alpha', '/a') . $entry('Beta', '/b') . '</div></div>'
. '<div class="nav-item"><a href="/contact">Contact</a></div>'
. '</nav></header>'
);
$assert(
array( 'link:Home', 'submenu:Services', 'link:Alpha', 'link:Beta', 'end', 'link:Contact' ) === $labels($folder),
'a folder titled by a link keeps its wrapped entries as its submenu',
json_encode($labels($folder))
);
$services = (string) ($folder[1] ?? '');
$assert(str_contains($services, 'nav-item') && str_contains($services, 'nav-item--folder'), 'the folder title keeps the item classes', $services);

$span = $items(
'<nav class="site-menu"><div class="entry"><span><a href="/shop">Shop</a></span><div class="children"><a href="/shop/new">New</a></div></div>'
. '<div class="entry"><a href="/about">About</a></div></nav>'
);
$assert(
array( 'submenu:Shop', 'link:New', 'end', 'link:About' ) === $labels($span),
'a parent link in a label wrapper owns the cluster beside it',
json_encode($labels($span))
);

$peers = $items(
'<header><div class="menu-panel"><div class="menu-nav"><nav class="menu-nav-list"><div class="nav-wrapper">'
. '<div class="nav-item"><a href="/">Home</a></div><div class="nav-item"><a href="/a">A</a></div><div class="nav-item"><a href="/b">B</a></div>'
. '</div></nav></div></div></header>'
);
$assert(! in_array('submenu:Home', $labels($peers), true), 'a wrapper of the same tag beside the label stays a peer', json_encode($labels($peers)));

// Shapes that read as peers or as titled columns stay as trunk: no submenu.
$noSubmenu = array(
'a label of another tag beside peer items in a list' => '<nav class="nav"><div class="list"><span class="item current"><a href="/">Home</a></span><div class="item"><a href="/about">About</a></div><div class="item"><a href="/contact">Contact</a></div></div></nav>',
'a paragraph item beside a div item sharing its class' => '<nav class="nav"><div class="row"><p class="item"><a href="/">Home</a></p><div class="item"><a href="/about">About</a><a href="/team">Team</a></div></div></nav>',
'footer columns titled by linked headings' => '<footer><nav aria-label="Footer" class="cols"><div class="col"><h4><a href="/shop">Shop</a></h4><div class="links"><a href="/shoes">Shoes</a><a href="/bags">Bags</a></div></div>'
. '<div class="col"><h4><a href="/help">Help</a></h4><div class="links"><a href="/faq">FAQ</a><a href="/returns">Returns</a></div></div></nav></footer>',
);
foreach ( $noSubmenu as $case => $html ) {
$read = $labels($items($html));
$assert(array() === array_filter($read, static fn (string $item): bool => str_starts_with($item, 'submenu:')), $case . ' gets no submenu', json_encode($read));
}

if ( 0 < $failures ) {
fwrite(STDERR, "Navigation owned submenu panel: {$failures} failed, {$passes} passed\n");
exit(1);
}

echo "Navigation owned submenu panel passed: {$passes} assertions\n";
Loading