diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 865190ef6..a9b884135 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -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", diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index 33b69c23d..f47ed6be1 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -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; } @@ -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 ) ) ) { @@ -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: `
+ *
Mission
Team
`. 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 + * (`
Parent
Child
`). 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 (`
One
Two
`). + */ + 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; } /** diff --git a/php-transformer/tests/unit/navigation-controlled-dropdown-item.php b/php-transformer/tests/unit/navigation-controlled-dropdown-item.php new file mode 100644 index 000000000..4d061c067 --- /dev/null +++ b/php-transformer/tests/unit/navigation-controlled-dropdown-item.php @@ -0,0 +1,74 @@ + 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 => '
' . $label . '
'; + $html = '
'; + $serialized = (string) (( new HtmlTransformer() )->transform($html, array())->toArray()['serialized_blocks'] ?? ''); + preg_match_all('/