From 33af2b9d1f9a08c8fb1b7cfe925529c3d162bd5b Mon Sep 17 00:00:00 2001 From: Ellen Bauer Date: Wed, 14 Oct 2026 11:05:33 +0700 Subject: [PATCH 1/3] fix(php-transformer): read a button that controls a sibling link panel as a submenu A menu item made of a labelled button and the sibling panel it names with aria-controls (`div > button[aria-controls=p] + div#p > div > a ...`) had no reading. Squarespace's desktop folders use it. Before #2743 the first child link was taken as the submenu label; after it the child links were flattened into the top row; with generic names the folder was dropped. buttonDropdownItem() now also accepts that shape when every panel entry is a link, bare or in its own wrapper. The button label becomes the submenu label and the panel's links its children. Refs #2726 Co-Authored-By: Claude Opus 5.5 --- php-transformer/composer.json | 1 + .../Patterns/NavigationPattern.php | 51 +++++++++++++- .../navigation-controlled-dropdown-item.php | 67 +++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 php-transformer/tests/unit/navigation-controlled-dropdown-item.php diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 865190ef6..be225132f 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -377,6 +377,7 @@ "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-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..33fb2f25e 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -3367,7 +3367,56 @@ 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: `
`. 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+/', ' ', $button->textContent ?? '') ?? ''); + if ( '' === $controls || $controls !== trim($panel->getAttribute('id')) || '' === $label ) { + return null; + } + $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')) ) { + return null; + } + ++$links; + } + + return 0 < $links ? array( 'label' => $label, 'cluster' => $panel, 'button' => $button ) : null; } /** 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..05ee83344 --- /dev/null +++ b/php-transformer/tests/unit/navigation-controlled-dropdown-item.php @@ -0,0 +1,67 @@ + 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 => ''; + $html = '
'; + $serialized = (string) (( new HtmlTransformer() )->transform($html, array())->toArray()['serialized_blocks'] ?? ''); + preg_match_all('/