diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 2a408ad72..df0db4f25 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -375,6 +375,8 @@ "php tests/unit/navigation-peer-item-wrappers.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", + "php tests/contract/shared-menu-with-folded-dialog.php", "php tests/unit/paragraph-label-desktop-menu.php", "php tests/unit/button-signal-classifier.php", "php tests/unit/pill-button-anchor.php", diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index 581eb18be..33b69c23d 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -11,6 +11,7 @@ use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\AuthoredButtonBlockGenerator; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\AuthorSelectorProjectionState; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\StyleAttributeMapper; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\CapturedMenuDialogFold; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\LinkUrlSanitizer; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SourceDom; use DOMElement; @@ -57,6 +58,15 @@ final class NavigationPattern implements PatternRecognizerInterface */ private const LIST_NAVIGATION_CLASS = 'blocks-engine-list-navigation'; + /** The navigation that took over an absorbed menu dialog. */ + private const MENU_FOLD_NAVIGATION_CLASS = 'blocks-engine-menu-fold-navigation'; + + /** A row control the absorbed menu dialog repeats; hidden where the hamburger takes over. */ + private const MENU_FOLD_HIDE_CLASS = 'blocks-engine-menu-fold-hide'; + + /** A dialog entry carried into the overlay; hidden where the row shows in full. */ + private const MENU_FOLD_OVERLAY_ONLY_CLASS = 'blocks-engine-menu-fold-overlay-only'; + public function claimsBeforeAuthorOwnedLayout(DOMElement $element): bool { $tag = strtolower($element->tagName); @@ -856,6 +866,20 @@ private function menuRowBesideControls(DOMElement $row, PatternContext $context, return null; } + // A captured mobile menu that repeats this row belongs to the row's + // own navigation. It stays only if the row can take it over whole. + $fold = $navigationContext?->capturedMenuFold($row); + $result = $this->menuRowSlots($row, $converter, $presentationAttributes, $innerHtml, $createBlock, $navigationContext, $fold); + if ( null === $result && $fold instanceof CapturedMenuDialogFold ) { + $navigationContext?->releaseCapturedMenuFold($fold); + return $this->menuRowSlots($row, $converter, $presentationAttributes, $innerHtml, $createBlock, $navigationContext, null); + } + + return $result; + } + + private function menuRowSlots(DOMElement $row, PatternTreeConverter $converter, callable $presentationAttributes, callable $innerHtml, callable $createBlock, ?NavigationPatternContext $navigationContext, ?CapturedMenuDialogFold $fold): ?PatternRecognitionResult + { $buttonSignals = new ButtonSignalClassifier(); $slots = array(); foreach ( $row->childNodes as $child ) { @@ -877,21 +901,37 @@ private function menuRowBesideControls(DOMElement $row, PatternContext $context, } } + // One run of items can take the overlay; a row split into several runs + // keeps its dialog the way it was captured. + $itemRuns = count(array_filter($slots, static fn (array $slot): bool => 'items' === $slot['kind'])); + if ( $fold instanceof CapturedMenuDialogFold && 1 !== $itemRuns ) { + return null; + } + $fallbacks = array(); $children = array(); $collected = array(); $linkSets = array(); + $foldMarker = ''; foreach ( $slots as $slot ) { if ( 'control' === $slot['kind'] ) { $block = $converter->element($slot['elements'][0], $fallbacks, true); if ( ! is_array($block) || in_array((string) ( $block['blockName'] ?? '' ), array( '', 'core/html' ), true) ) { return null; } + // The dialog lists this control too, and carries its own copy + // into the overlay, so the row's copy is hidden below the + // breakpoint where the hamburger takes over. + if ( $fold instanceof CapturedMenuDialogFold && $fold->listsControl($slot['elements'][0]) ) { + $block = $this->withBlockClassName($block, self::MENU_FOLD_HIDE_CLASS, $createBlock, $slot['elements'][0]); + } $children[] = $block; continue; } $links = array(); + $itemLinks = array(); + $overlayExtras = array(); foreach ( $slot['elements'] as $item ) { $link = 'a' === strtolower($item->tagName) ? $this->navigationLinkBlock($item, $presentationAttributes, $innerHtml, $createBlock, $item, $navigationContext) @@ -900,15 +940,27 @@ private function menuRowBesideControls(DOMElement $row, PatternContext $context, return null; } $links[] = $link; + if ( 'a' === strtolower($item->tagName) ) { + $itemLinks[CapturedMenuDialogFold::signature('link', CapturedMenuDialogFold::label($item), trim($item->getAttribute('href')))] = true; + } } $attrs = array( 'overlayMenu' => 'never' ); $gap = trim((string) ( $this->resolvedNavigationSpacing($navigationContext?->resolvedStyle($row) ?? '')['blockGap'] ?? '' )); if ( '' !== $gap ) { $attrs['style']['spacing']['blockGap'] = $gap; } + if ( $fold instanceof CapturedMenuDialogFold ) { + $overlay = $this->foldedOverlayAttributes($row, $fold, $attrs, $itemLinks, $converter, $createBlock, $navigationContext, $fallbacks); + if ( null === $overlay ) { + return null; + } + [ $attrs, $overlayExtras, $foldMarker ] = $overlay; + } + // Shared link text settings are read from the menu's own items; the + // overlay-only extras are not items and must not dilute them. $attrs = array_replace_recursive($attrs, $this->commonNavigationLinkTextAttributes($links)); $linkSets[] = $links; - $children[] = $createBlock('core/navigation', $attrs, $links, $slot['elements'][0]); + $children[] = $createBlock('core/navigation', $attrs, array_merge($links, $overlayExtras), $slot['elements'][0]); } if ( array() === $linkSets ) { return null; @@ -916,12 +968,111 @@ private function menuRowBesideControls(DOMElement $row, PatternContext $context, $this->recordNavigationSources(array(), array(), $collected, $row, $navigationContext); + $groupAttrs = array_merge($presentationAttributes($row), array( 'tagName' => 'div' )); + if ( '' !== $foldMarker ) { + $groupAttrs = $this->withClassName($groupAttrs, $foldMarker); + } + return new PatternRecognitionResult( - $createBlock('core/group', array_merge($presentationAttributes($row), array( 'tagName' => 'div' )), $children, $row), + $createBlock('core/group', $groupAttrs, $children, $row), $fallbacks ); } + /** + * Turn the row's single navigation into the responsive one that replaces + * the hamburger and the dialog. + * + * The navigation takes core's overlay and the hamburger's look. Dialog + * entries the row shows outside its items (a call-to-action link, say) go + * into the overlay as well, hidden once the row is back to its wide layout. + * Below the breakpoint the row is shown again, with the controls the dialog + * lists hidden, so the page keeps one hamburger and one copy of each + * control. Returns null when an overlay entry cannot be converted. + * + * @param array $attrs + * @param array $itemLinks + * @param array> $fallbacks + * @return array{0: array, 1: array>, 2: string}|null The attributes, the overlay-only blocks, and the row marker. + */ + private function foldedOverlayAttributes(DOMElement $row, CapturedMenuDialogFold $fold, array $attrs, array $itemLinks, PatternTreeConverter $converter, callable $createBlock, ?NavigationPatternContext $navigationContext, array &$fallbacks): ?array + { + if ( null === $navigationContext ) { + return null; + } + + $extras = array(); + foreach ( $fold->dialog->getElementsByTagName('a') as $anchor ) { + if ( ! $anchor instanceof DOMElement + || isset($itemLinks[CapturedMenuDialogFold::signature('link', CapturedMenuDialogFold::label($anchor), trim($anchor->getAttribute('href')))]) + ) { + continue; + } + $block = $converter->element($anchor, $fallbacks, true); + if ( ! is_array($block) || in_array((string) ( $block['blockName'] ?? '' ), array( '', 'core/html' ), true) ) { + return null; + } + $extras[] = $this->withBlockClassName($block, self::MENU_FOLD_OVERLAY_ONLY_CLASS, $createBlock, $anchor); + } + + $attrs['overlayMenu'] = 'mobile'; + // The source's dropdown buttons and its mobile accordions both open on + // click; Core keeps overlay submenus collapsed until then only in click mode. + $attrs['openSubmenusOnClick'] = true; + $attrs = $this->withClassName($attrs, 'blocks-engine-native-responsive-navigation ' . self::MENU_FOLD_NAVIGATION_CLASS); + $attrs = $this->withResponsiveToggleMarker($attrs, $row, $navigationContext); + // The open overlay is the dialog's panel, so its paint comes from the dialog. + $attrs = $this->withResponsiveOverlayMarker($attrs, $fold->dialog, $navigationContext); + + $boundary = $navigationContext->menuCollapseBreakpoint($row, $fold->triggers[0]); + $below = null === $boundary || $boundary <= 600 ? 599 : (int) floor($boundary); + $display = 'flex'; + if ( 1 === preg_match('/(?:^|;)\s*display\s*:\s*(flex|inline-flex|grid|block)\b/i', $navigationContext->resolvedStyle($row), $match) ) { + $display = strtolower($match[1]); + } + $hostRule = '.wp-block-group.%s{display:' . $display . '!important}' + . '.wp-block-group.%s>.' . self::MENU_FOLD_HIDE_CLASS . '{display:none!important}'; + $groupGap = $fold->group instanceof DOMElement + ? trim((string) ( $this->resolvedNavigationSpacing($navigationContext->resolvedStyle($fold->group))['blockGap'] ?? '' )) + : ''; + if ( '' !== $groupGap ) { + $hostRule .= '.wp-block-group.%s{gap:' . $groupGap . '!important}'; + } + if ( $fold->hamburgerLast ) { + $hostRule .= '.wp-block-group.%s>.wp-block-navigation{order:99}'; + } + $marker = 'blocks-engine-menu-fold-' . substr(hash('sha256', $below . $display . $groupGap . ( $fold->hamburgerLast ? 'last' : '' )), 0, 12); + $host = '.wp-block-navigation.' . self::MENU_FOLD_NAVIGATION_CLASS; + $rule = '@media(max-width:' . $below . 'px){' . str_replace('%s', $marker, $hostRule) . '}' + . '@media(min-width:' . ( $below + 1 ) . 'px){.wp-block-group.' . $marker . ' .' . self::MENU_FOLD_OVERLAY_ONLY_CLASS . '{display:none!important}}' + // The engine's list-navigation repairs lay items out as list items. + // These items are the row's own dropdown entries, which Core already + // lays out as flex items, so keep that. + . $host . ' .wp-block-navigation-item{display:flex}' + . $host . ' .wp-block-navigation-item__content{display:block}' + // A header that blurs what is behind it becomes the containing block + // for fixed descendants. While the overlay is open the header gives + // that up so the overlay covers the screen the way Core draws it. + . ':is(nav,header,div,section):has(' . $host . ' .wp-block-navigation__responsive-container.is-menu-open){backdrop-filter:none!important;-webkit-backdrop-filter:none!important;filter:none!important}'; + $navigationContext->registerMenuFoldRule($marker, $rule); + + return array( $attrs, $extras, $marker ); + } + + /** + * Add a class to a block that is already built. The block is created again + * from its own attributes, so the saved markup carries the class too. + * + * @param array $block + * @return array + */ + private function withBlockClassName(array $block, string $className, callable $createBlock, DOMElement $source): array + { + $attrs = $this->withClassName(is_array($block['attrs'] ?? null) ? $block['attrs'] : array(), $className); + + return $createBlock((string) $block['blockName'], $attrs, is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array(), $source); + } + /** * Per-link colour support is retained in block attributes for diagnostics, * but core/navigation-link does not render it. Add a deterministic class to diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPatternContext.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPatternContext.php index f1bfff4f3..6d70234b1 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPatternContext.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPatternContext.php @@ -11,6 +11,7 @@ use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueInspector; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\NavigationStyleProjector; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\StyleResolver; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\CapturedMenuDialogFold; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\NavigationToggleSuppressor; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SourceDom; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SvgMaterializer; @@ -499,6 +500,27 @@ public function overlayMenu(DOMElement $element): string return $this->navigationToggleSuppressor?->navigationOverlayMenu($element) ?? 'never'; } + public function capturedMenuFold(DOMElement $row): ?CapturedMenuDialogFold + { + return $this->navigationToggleSuppressor?->capturedMenuFoldForRow($row); + } + + public function releaseCapturedMenuFold(CapturedMenuDialogFold $fold): void + { + $this->navigationToggleSuppressor?->releaseCapturedMenuFold($fold); + } + + /** The width (px) at and below which the source shows the hamburger instead of the row. */ + public function menuCollapseBreakpoint(DOMElement $row, DOMElement $trigger): ?float + { + return $this->navigationToggleSuppressor?->sourceCollapseBreakpoint($row, $trigger); + } + + public function registerMenuFoldRule(string $marker, string $rule): void + { + $this->session?->generatedSupportStylesheetState()->registerNativeNavigationOverlay($marker, $rule); + } + public function responsiveToggleMarker(DOMElement $element): string { return $this->projectedNavigation?->responsiveNavigationToggleMarker($element) ?? ''; diff --git a/php-transformer/src/HtmlToBlocks/Session/NavigationProjectionState.php b/php-transformer/src/HtmlToBlocks/Session/NavigationProjectionState.php index 9994d76d5..c09172e28 100644 --- a/php-transformer/src/HtmlToBlocks/Session/NavigationProjectionState.php +++ b/php-transformer/src/HtmlToBlocks/Session/NavigationProjectionState.php @@ -3,6 +3,7 @@ namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Session; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\CapturedMenuDialogFold; use DOMElement; /** @@ -28,6 +29,12 @@ final class NavigationProjectionState /** @var array */ private array $implicitDialogControlPaths = array(); + /** @var array menu row path => the captured menu dialog it absorbs */ + private array $foldsByRowPath = array(); + + /** @var array hamburger path => the fold it opens */ + private array $foldsByTriggerPath = array(); + public function hasProjection(): bool { return array() !== $this->targetsByControlPath; @@ -67,4 +74,37 @@ public function isImplicitDialogControl(DOMElement $control): bool { return isset($this->implicitDialogControlPaths[$control->getNodePath()]); } + + public function foldMenuDialog(CapturedMenuDialogFold $fold): void + { + $this->foldsByRowPath[$fold->row->getNodePath()] = $fold; + foreach ( $fold->triggers as $trigger ) { + $this->foldsByTriggerPath[$trigger->getNodePath()] = $fold; + } + foreach ( $fold->suppressed as $element ) { + $this->suppress($element); + } + } + + public function foldForRow(DOMElement $row): ?CapturedMenuDialogFold + { + return $this->foldsByRowPath[$row->getNodePath()] ?? null; + } + + public function foldForTrigger(DOMElement $trigger): ?CapturedMenuDialogFold + { + return $this->foldsByTriggerPath[$trigger->getNodePath()] ?? null; + } + + /** Give the dialog and hamburger back to the output when the row could not absorb them. */ + public function releaseFold(CapturedMenuDialogFold $fold): void + { + unset($this->foldsByRowPath[$fold->row->getNodePath()]); + foreach ( $fold->triggers as $trigger ) { + unset($this->foldsByTriggerPath[$trigger->getNodePath()]); + } + foreach ( $fold->suppressed as $element ) { + unset($this->suppressedPaths[$element->getNodePath()]); + } + } } diff --git a/php-transformer/src/HtmlToBlocks/Support/CapturedMenuDialogFold.php b/php-transformer/src/HtmlToBlocks/Support/CapturedMenuDialogFold.php new file mode 100644 index 000000000..a722cb375 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Support/CapturedMenuDialogFold.php @@ -0,0 +1,460 @@ + $triggers The controls that open the dialog. + * @param DOMElement $dialog The captured dialog. + * @param list $suppressed Source elements the folded output no longer needs. + * @param array $entrySignatures What the dialog lists, keyed `link:label|href` or `button:label`. + * @param list $linkEntries The dialog's plain link entries, in order. + * @param DOMElement|null $group The hamburger's wrapper, when it only repeated controls the row already has. + * @param bool $hamburgerLast Whether the hamburger came after those controls in that wrapper. + */ + private function __construct( + public readonly DOMElement $row, + public readonly array $triggers, + public readonly DOMElement $dialog, + public readonly array $suppressed, + public readonly array $entrySignatures, + public readonly array $linkEntries, + public readonly ?DOMElement $group, + public readonly bool $hamburgerLast + ) { + } + + /** @return list */ + public static function detect(DOMElement $root): array + { + $document = $root->ownerDocument; + if ( ! $document instanceof DOMDocument ) { + return array(); + } + + $folds = array(); + foreach ( iterator_to_array($root->getElementsByTagName('dialog')) as $dialog ) { + if ( ! $dialog instanceof DOMElement || 'true' !== $dialog->getAttribute('data-blocks-engine-captured-menu') ) { + continue; + } + $triggers = array(); + foreach ( preg_split('/\s+/', trim($dialog->getAttribute('data-blocks-engine-triggers'))) ?: array() as $id ) { + $trigger = '' === $id ? null : $document->getElementById($id); + if ( $trigger instanceof DOMElement ) { + $triggers[] = $trigger; + } + } + $entries = self::entries($dialog, 0); + if ( array() === $triggers || null === $entries || array() === $entries ) { + continue; + } + + $row = self::equivalentRow($root, $dialog, $triggers, $entries); + if ( ! $row instanceof DOMElement ) { + continue; + } + + $signatures = array(); + $links = array(); + foreach ( $entries as $entry ) { + $signatures[self::signature($entry['kind'], $entry['label'], $entry['href'])] = true; + if ( 'link' === $entry['kind'] ) { + $links[] = array( 'label' => $entry['label'], 'href' => $entry['href'] ); + } + } + $suppressed = array( $dialog ); + $group = self::redundantTriggerGroup($row, $triggers, $dialog); + if ( $group instanceof DOMElement ) { + $suppressed[] = $group; + } else { + array_push($suppressed, ...$triggers); + } + $folds[] = new self($row, $triggers, $dialog, $suppressed, $signatures, $links, $group, $group instanceof DOMElement && self::followsControls($group, $triggers[0])); + } + + return $folds; + } + + /** Whether the dialog lists this control: a link by label and href, or every link and button inside it. */ + public function listsControl(DOMElement $control): bool + { + $tag = strtolower($control->tagName); + if ( 'a' === $tag ) { + return isset($this->entrySignatures[self::signature('link', self::label($control), trim($control->getAttribute('href')))]); + } + if ( 'button' === $tag ) { + return isset($this->entrySignatures[self::signature('button', self::label($control), '')]); + } + + $found = 0; + foreach ( $control->getElementsByTagName('*') as $descendant ) { + if ( ! $descendant instanceof DOMElement || ! in_array(strtolower($descendant->tagName), array( 'a', 'button' ), true) ) { + continue; + } + if ( ! $this->listsControl($descendant) ) { + return false; + } + ++$found; + } + + return 0 < $found; + } + + public static function signature(string $kind, string $label, string $href): string + { + return $kind . ':' . $label . ( 'link' === $kind ? '|' . $href : '' ); + } + + public static function label(DOMElement $element): string + { + $text = ''; + foreach ( $element->childNodes as $child ) { + $text .= self::textWithoutArtwork($child); + } + + return strtolower(trim(preg_replace('/\s+/', ' ', $text) ?? '')); + } + + private static function textWithoutArtwork(DOMNode $node): string + { + if ( $node instanceof DOMElement ) { + if ( in_array(strtolower($node->tagName), array( 'svg', 'script', 'style' ), true) ) { + return ''; + } + $text = ''; + foreach ( $node->childNodes as $child ) { + $text .= self::textWithoutArtwork($child); + } + + return $text; + } + + return XML_TEXT_NODE === $node->nodeType ? (string) $node->textContent : ''; + } + + /** + * What a dialog lists, as plain links and accordion buttons. A dialog with + * anything else in it is not a plain menu copy. + * + * @return list}>|null + */ + private static function entries(DOMElement $container, int $depth): ?array + { + if ( 4 < $depth ) { + return null; + } + $entries = array(); + foreach ( $container->childNodes as $child ) { + if ( XML_COMMENT_NODE === $child->nodeType || ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) ) { + continue; + } + if ( ! $child instanceof DOMElement ) { + return null; + } + $tag = strtolower($child->tagName); + if ( 'a' === $tag ) { + $label = self::label($child); + $href = trim($child->getAttribute('href')); + if ( '' === $label || '' === $href ) { + return null; + } + $entries[] = array( 'kind' => 'link', 'label' => $label, 'href' => $href, 'children' => array() ); + continue; + } + if ( 'button' === $tag ) { + $label = self::label($child); + if ( '' === $label ) { + return null; + } + $entries[] = array( 'kind' => 'button', 'label' => $label, 'href' => '', 'children' => array() ); + continue; + } + if ( ! in_array($tag, array( 'div', 'span', 'li', 'ul', 'ol', 'nav' ), true) ) { + return null; + } + + $button = null; + foreach ( $child->childNodes as $grandchild ) { + if ( $grandchild instanceof DOMElement && 'button' === strtolower($grandchild->tagName) ) { + $button = $grandchild; + break; + } + } + if ( ! $button instanceof DOMElement ) { + $nested = self::entries($child, $depth + 1); + if ( null === $nested ) { + return null; + } + array_push($entries, ...$nested); + continue; + } + + $label = self::label($button); + if ( '' === $label ) { + return null; + } + $children = array(); + foreach ( $child->getElementsByTagName('a') as $anchor ) { + if ( $anchor instanceof DOMElement ) { + $children[] = array( 'label' => self::label($anchor), 'href' => trim($anchor->getAttribute('href')) ); + } + } + $entries[] = array( 'kind' => 'button', 'label' => $label, 'href' => '', 'children' => $children ); + } + + return $entries; + } + + /** + * The one row that already has every entry. Entries that are accordion + * buttons must match a dropdown item by label (and, when the capture opened + * the accordion, by its links); links must match a link in the row by label + * and href. + * + * @param list $triggers + * @param list}> $entries + */ + private static function equivalentRow(DOMElement $root, DOMElement $dialog, array $triggers, array $entries): ?DOMElement + { + $matches = array(); + foreach ( $root->getElementsByTagName('*') as $candidate ) { + if ( ! $candidate instanceof DOMElement || SourceDom::elementContains($dialog, $candidate) || $candidate->isSameNode($dialog) ) { + continue; + } + foreach ( $triggers as $trigger ) { + if ( $candidate->isSameNode($trigger) || SourceDom::elementContains($candidate, $trigger) ) { + continue 2; + } + } + $dropdowns = self::dropdownItems($candidate); + if ( array() === $dropdowns ) { + continue; + } + if ( self::rowHasEntries($candidate, $dropdowns, $entries) ) { + $matches[] = $candidate; + } + } + + // Rows nest: a wrapper around the row also matches. The innermost one is the row. + foreach ( $matches as $index => $match ) { + foreach ( $matches as $other ) { + if ( ! $match->isSameNode($other) && SourceDom::elementContains($match, $other) ) { + unset($matches[$index]); + break; + } + } + } + + return 1 === count($matches) ? array_values($matches)[0] : null; + } + + /** @return array> dropdown label => its links */ + private static function dropdownItems(DOMElement $row): array + { + $items = array(); + foreach ( $row->childNodes as $child ) { + if ( ! $child instanceof DOMElement ) { + continue; + } + $parts = NavigationPattern::buttonDropdownItemParts($child); + if ( null === $parts ) { + continue; + } + $links = array(); + foreach ( $parts['cluster']->getElementsByTagName('a') as $anchor ) { + if ( $anchor instanceof DOMElement ) { + $links[] = array( 'label' => self::label($anchor), 'href' => trim($anchor->getAttribute('href')) ); + } + } + $items[strtolower($parts['label'])] = $links; + } + + return $items; + } + + /** + * @param array> $dropdowns + * @param list}> $entries + */ + private static function rowHasEntries(DOMElement $row, array $dropdowns, array $entries): bool + { + $anchors = array(); + foreach ( $row->getElementsByTagName('a') as $anchor ) { + if ( $anchor instanceof DOMElement ) { + $anchors[self::signature('link', self::label($anchor), trim($anchor->getAttribute('href')))] = true; + } + } + $buttons = array(); + foreach ( $row->getElementsByTagName('button') as $button ) { + if ( $button instanceof DOMElement ) { + $buttons[self::label($button)] = true; + } + } + + $matchedDropdown = false; + foreach ( $entries as $entry ) { + if ( 'link' === $entry['kind'] ) { + if ( ! isset($anchors[self::signature('link', $entry['label'], $entry['href'])]) ) { + return false; + } + continue; + } + if ( isset($dropdowns[$entry['label']]) ) { + $panelLinks = array_map(static fn (array $link): string => $link['label'] . '|' . $link['href'], $dropdowns[$entry['label']]); + foreach ( $entry['children'] as $child ) { + if ( ! in_array($child['label'] . '|' . $child['href'], $panelLinks, true) ) { + return false; + } + } + $matchedDropdown = true; + continue; + } + if ( ! isset($buttons[$entry['label']]) ) { + return false; + } + } + + return $matchedDropdown; + } + + /** + * The hamburger's own wrapper, when everything else in it is something the + * row already shows (a second copy of a language switcher, say). Dropping + * the wrapper then leaves one copy of each control. Returns null when the + * wrapper holds anything the row does not, so only the hamburger goes. + * + * @param list $triggers + */ + private static function redundantTriggerGroup(DOMElement $row, array $triggers, DOMElement $dialog): ?DOMElement + { + $trigger = $triggers[0]; + $group = $trigger; + while ( $group->parentNode instanceof DOMElement && ! SourceDom::elementContains($group->parentNode, $row) ) { + $group = $group->parentNode; + } + if ( $group->isSameNode($trigger) || SourceDom::elementContains($group, $dialog) || SourceDom::elementContains($group, $row) ) { + return null; + } + foreach ( $triggers as $other ) { + if ( ! SourceDom::elementContains($group, $other) ) { + return null; + } + } + + $rowLinks = array(); + foreach ( $row->getElementsByTagName('a') as $anchor ) { + if ( $anchor instanceof DOMElement ) { + $rowLinks[self::signature('link', self::label($anchor), trim($anchor->getAttribute('href')))] = true; + } + } + $rowButtons = array(); + foreach ( $row->getElementsByTagName('button') as $button ) { + if ( $button instanceof DOMElement ) { + $rowButtons[self::label($button)] = true; + } + } + + foreach ( $group->getElementsByTagName('*') as $descendant ) { + if ( ! $descendant instanceof DOMElement ) { + continue; + } + foreach ( $triggers as $other ) { + if ( $descendant->isSameNode($other) || SourceDom::elementContains($other, $descendant) ) { + continue 2; + } + } + $tag = strtolower($descendant->tagName); + if ( 'a' === $tag ) { + if ( ! isset($rowLinks[self::signature('link', self::label($descendant), trim($descendant->getAttribute('href')))]) ) { + return null; + } + } elseif ( 'button' === $tag ) { + if ( ! isset($rowButtons[self::label($descendant)]) ) { + return null; + } + } elseif ( in_array($tag, array( 'img', 'svg', 'picture', 'video', 'input', 'select', 'textarea', 'form', 'iframe', 'canvas' ), true) ) { + if ( ! self::insideMatchedControl($descendant, $group) ) { + return null; + } + } + } + + // Text outside every link and button must be bare punctuation, such as + // the divider between two switcher buttons. + return self::onlyPunctuationOutsideControls($group, $triggers) ? $group : null; + } + + /** Whether a link or button of the wrapper comes before the hamburger in document order. */ + private static function followsControls(DOMElement $group, DOMElement $trigger): bool + { + foreach ( $group->getElementsByTagName('*') as $descendant ) { + if ( ! $descendant instanceof DOMElement ) { + continue; + } + if ( $descendant->isSameNode($trigger) ) { + return false; + } + if ( in_array(strtolower($descendant->tagName), array( 'a', 'button' ), true) ) { + return true; + } + } + + return false; + } + + private static function insideMatchedControl(DOMElement $element, DOMElement $group): bool + { + for ( $node = $element->parentNode; $node instanceof DOMElement && ! $node->isSameNode($group); $node = $node->parentNode ) { + if ( in_array(strtolower($node->tagName), array( 'a', 'button' ), true) ) { + return true; + } + } + + return false; + } + + /** @param list $triggers */ + private static function onlyPunctuationOutsideControls(DOMNode $node, array $triggers): bool + { + foreach ( $node->childNodes as $child ) { + if ( $child instanceof DOMElement ) { + if ( in_array(strtolower($child->tagName), array( 'a', 'button', 'svg' ), true) ) { + continue; + } + if ( ! self::onlyPunctuationOutsideControls($child, $triggers) ) { + return false; + } + continue; + } + if ( XML_TEXT_NODE === $child->nodeType && 1 === preg_match('/[\p{L}\p{N}]/u', (string) $child->textContent) ) { + return false; + } + } + + return true; + } +} diff --git a/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressor.php b/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressor.php index 6e4aba9b7..8175fe888 100644 --- a/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressor.php +++ b/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressor.php @@ -64,6 +64,13 @@ public function forgetCoreNavigationRecognitions(): void */ public function collectProjectedNavigationRelationships(DOMElement $root): void { + // A captured menu dialog that repeats a dropdown row belongs to that + // row's navigation. Record it first so the hamburger and the panel are + // never offered to the projections below. + foreach ( CapturedMenuDialogFold::detect($root) as $fold ) { + $this->context->navigationProjection()->foldMenuDialog($fold); + } + $elementsById = array(); foreach ( $root->getElementsByTagName('*') as $element ) { if ( $element instanceof DOMElement && '' !== trim(SourceDom::attr($element, 'id')) ) { @@ -1253,8 +1260,23 @@ public function navigationOverlayMenu(DOMElement $navigation): string return $this->navigationToggleControl($navigation) instanceof DOMElement ? 'mobile' : 'never'; } + /** The captured menu dialog a dropdown row has absorbed, if any. */ + public function capturedMenuFoldForRow(DOMElement $row): ?CapturedMenuDialogFold + { + return $this->context->navigationProjection()->foldForRow($row); + } + + public function releaseCapturedMenuFold(CapturedMenuDialogFold $fold): void + { + $this->context->navigationProjection()->releaseFold($fold); + } + public function navigationToggleControl(DOMElement $navigation): ?DOMElement { + $fold = $this->context->navigationProjection()->foldForRow($navigation); + if ( $fold instanceof CapturedMenuDialogFold ) { + return $fold->triggers[0]; + } $document = $navigation->ownerDocument; if ( ! $document instanceof DOMDocument ) { @@ -1527,6 +1549,12 @@ public function isHashAnchorMenuProjection(DOMElement $element): bool */ public function projectedOverlayMenu(DOMElement $control): string { + // The hamburger of an absorbed menu dialog is hidden at the wide + // reference viewport, so the navigation that takes it over shows its + // own button only on narrow screens. + if ( $this->context->navigationProjection()->foldForTrigger($control) instanceof CapturedMenuDialogFold ) { + return 'mobile'; + } if ( $control->hasAttribute(CaptureEvidence::DIALOG_TRIGGER) ) { return 'always'; } diff --git a/php-transformer/src/WordPressSitePlan/ShellExtraction.php b/php-transformer/src/WordPressSitePlan/ShellExtraction.php index 2a50ce3e1..16070d27e 100644 --- a/php-transformer/src/WordPressSitePlan/ShellExtraction.php +++ b/php-transformer/src/WordPressSitePlan/ShellExtraction.php @@ -227,11 +227,16 @@ private function unlabeledChromeCandidates(string $markup, string $sourcePath, a private function collectNestedChrome(string $markup, int $baseOffset, array &$headers, array &$footers): void { $ranges = self::topLevelBlockRanges($markup); - if (2 <= count($ranges)) { - $first = substr($markup, $ranges[0]['offset'], $ranges[0]['length']); + // Empty decoration groups ahead of the header do not move it: the first + // block that holds anything is the one that may be the header. + $lead = 0; + while ($lead < count($ranges) - 1 && self::isEmptyVisualGroup(substr($markup, $ranges[$lead]['offset'], $ranges[$lead]['length']))) ++$lead; + if (2 <= count($ranges) - $lead) { + $head = $ranges[$lead]; + $first = substr($markup, $head['offset'], $head['length']); if (self::containsNavigation($first) && !self::containsMainLandmark($first) && !self::chromeSplitIsInside($first)) { $restHasContent = false; - foreach (array_slice($ranges, 1) as $range) { + foreach (array_slice($ranges, $lead + 1) as $range) { $sibling = substr($markup, $range['offset'], $range['length']); if (!self::isEmptyVisualGroup($sibling)) { $restHasContent = true; @@ -239,15 +244,15 @@ private function collectNestedChrome(string $markup, int $baseOffset, array &$he } } if ($restHasContent) { - $headers[] = $this->nestedChromeRow($first, $baseOffset + $ranges[0]['offset'], $ranges[0]['length']); + $headers[] = $this->nestedChromeRow($first, $baseOffset + $head['offset'], $head['length']); // A projected modal dialog mounts at the end of its document; // it renders in the top layer, so the footer is the last // sibling before it. $lastIndex = count($ranges) - 1; - while ($lastIndex > 0 && self::isCapturedModalDialogBlock(substr($markup, $ranges[$lastIndex]['offset'], $ranges[$lastIndex]['length']))) --$lastIndex; + while ($lastIndex > $lead && self::isCapturedModalDialogBlock(substr($markup, $ranges[$lastIndex]['offset'], $ranges[$lastIndex]['length']))) --$lastIndex; $last = $ranges[$lastIndex]; $lastMarkup = substr($markup, $last['offset'], $last['length']); - if ($last !== $ranges[0] && self::isFooterChrome($lastMarkup)) { + if ($last !== $head && self::isFooterChrome($lastMarkup)) { $footers[] = $this->nestedChromeRow($lastMarkup, $baseOffset + $last['offset'], $last['length']); } return; diff --git a/php-transformer/tests/contract/shared-menu-with-folded-dialog.php b/php-transformer/tests/contract/shared-menu-with-folded-dialog.php new file mode 100644 index 000000000..619540a67 --- /dev/null +++ b/php-transformer/tests/contract/shared-menu-with-folded-dialog.php @@ -0,0 +1,57 @@ + + '
' + . '
'; +$switcher = '
|
'; +$accordion = static fn (string $label): string => '
'; +$decor = ''; +$page = static fn (string $title, bool $decorated): string => '' . ($decorated ? $decor . '
' : '') + . '' + . '

' . $title . '

' . $title . ' page.

Acme

'; + +$plan = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array( + 'index.html' => $page('Home', false), + 'about.html' => $page('About', true), + 'contact.html' => $page('Contact', true), +)))->toArray()['source_reports']['wordpress_site_plan']; + +$menus = array_values(array_filter($plan['menus'] ?? array(), static fn(array $menu): bool => is_string($menu['token'] ?? null) && is_string($menu['block_markup'] ?? null))); +$headers = array_values(array_filter($plan['template_parts'] ?? array(), static fn(array $part): bool => 'header' === ($part['area'] ?? null))); +$assert(1 === count($menus), 'Three pages with the same folded menu share one navigation entity, got ' . count($menus) . '.'); +$assert(str_contains((string) $menus[0]['block_markup'], 'blocks-engine-menu-fold-overlay-only'), 'The entity carries the overlay-only call to action.'); +$assert(1 === count($headers), 'The repeated header becomes one header part.'); +$markup = (string) ($headers[0]['canonical_block_markup'] ?? ''); +$assert(str_contains($markup, '"overlayMenu":"mobile"') && ! str_contains($markup, 'captured-dialog'), 'The header part holds the responsive navigation and no dialog block.'); +foreach ($plan['pages'] as $planPage) { + $content = (string) ($planPage['block_markup'] ?? $planPage['canonical_block_markup'] ?? ''); + $assert(! str_contains($content, 'wp:navigation '), 'A page does not keep its own copy of the menu: ' . ($planPage['source_path'] ?? '?')); +} +echo "Shared folded menu contract passed\n"; diff --git a/php-transformer/tests/unit/navigation-menu-dialog-fold.php b/php-transformer/tests/unit/navigation-menu-dialog-fold.php new file mode 100644 index 000000000..585c61db8 --- /dev/null +++ b/php-transformer/tests/unit/navigation-menu-dialog-fold.php @@ -0,0 +1,111 @@ +> $blocks */ +$findBlocks = static function (array $blocks, string $name) use (&$findBlocks): array { + $found = array(); + foreach ( $blocks as $block ) { + if ( ! is_array($block) ) { + continue; + } + if ( $name === ($block['blockName'] ?? '') ) { + $found[] = $block; + } + $found = array_merge($found, $findBlocks(is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array(), $name)); + } + + return $found; +}; + +$css = '.hidden{display:none}.flex{display:flex}.items-center{align-items:center}.justify-between{justify-content:space-between}' + . '.gap-8{gap:2rem}.gap-3{gap:.75rem}.cta{display:inline-block;padding:8px 24px;border-radius:999px;background:#036;color:#fff}' + . '@media (min-width:768px){.md\\:flex{display:flex}.md\\:hidden{display:none}}'; +$item = static fn (string $id, string $label, string $links): string => + '
' + . '
'; +$switcher = '
|
'; +$cta = 'Book now'; +$accordion = static fn (string $label): string => '
'; + +$shop = 'New inSale'; +$help = 'FAQContact us'; +$page = static fn (string $dialog, string $mobileGroupExtra = ''): string => '' + . ''; +$dialog = '' + . $accordion('Shop') . $accordion('Help') . 'About' . 'Book now'; + +$transform = static fn (string $html): array => ( new HtmlTransformer() )->transform($html)->toArray(); +$result = $transform($page($dialog)); +$markup = (string) ($result['serialized_blocks'] ?? ''); +$blocks = is_array($result['blocks'] ?? null) ? $result['blocks'] : array(); +$navigations = $findBlocks($blocks, 'core/navigation'); + +$assert(1 === count($navigations), 'the row and the dialog are one core/navigation', $markup); +$assert('mobile' === ($navigations[0]['attrs']['overlayMenu'] ?? ''), 'that navigation owns the responsive overlay', $markup); +$assert(2 === count($findBlocks($blocks, 'core/navigation-submenu')), 'both dropdown items stay submenus', $markup); +$labels = array_map(static fn (array $block): string => (string) ($block['attrs']['label'] ?? ''), $findBlocks($blocks, 'core/navigation-link')); +foreach ( array( 'New in', 'Sale', 'FAQ', 'Contact us', 'About' ) as $label ) { + $assert(1 === count(array_keys($labels, $label, true)), 'the link "' . $label . '" is in the navigation once', json_encode($labels)); +} +$assert(! str_contains($markup, 'captured-dialog') && ! str_contains($markup, ' 'burger' === ($block['attrs']['anchor'] ?? '')); +$assert(array() === $hamburgers && ! str_contains($markup, 'id="burger"'), 'no separate hamburger button is left', $markup); +$assert(1 === substr_count($markup, '>EN<') && 1 === substr_count($markup, '>ES<'), 'the language switcher is kept once', $markup); +$assert(2 === substr_count($markup, 'Book now'), 'the call to action is kept in the row and in the overlay', $markup); +$assert(1 === count($findBlocks((array) ($navigations[0]['innerBlocks'] ?? array()), 'core/buttons')), 'the overlay carries the call to action', $markup); +$assert(str_contains($markup, 'blocks-engine-menu-fold-hide') && str_contains($markup, 'blocks-engine-menu-fold-overlay-only'), 'each copy of the call to action is marked for its own width', $markup); + +// A dialog that lists something the row does not have is not a copy of the row. +$other = '' + . $accordion('Shop') . $accordion('Help') . 'AboutCareers'; +$unmatched = $transform($page($other)); +$unmatchedMarkup = (string) ($unmatched['serialized_blocks'] ?? ''); +$assert(str_contains($unmatchedMarkup, 'captured-dialog'), 'a dialog with an entry the row lacks keeps its dialog block', $unmatchedMarkup); +$assert(! str_contains($unmatchedMarkup, 'blocks-engine-menu-fold'), 'and the row is not folded', $unmatchedMarkup); + +// The hamburger's wrapper keeps whatever the row does not have; only the hamburger goes. +$extra = $transform($page($dialog, 'Call us')); +$extraMarkup = (string) ($extra['serialized_blocks'] ?? ''); +$assert(! str_contains($extraMarkup, 'captured-dialog') && 1 === count($findBlocks(is_array($extra['blocks'] ?? null) ? $extra['blocks'] : array(), 'core/navigation')), 'a wrapper with its own content still folds the dialog into the navigation', $extraMarkup); +$assert(str_contains($extraMarkup, 'Call us') && 2 === substr_count($extraMarkup, '>EN<'), 'the wrapper and what it holds beyond the hamburger are kept', $extraMarkup); + +// A row that cannot hand its whole menu to one navigation keeps the dialog as captured. +$split = str_replace('About' . $switcher, $switcher . 'About', $page($dialog)); +$splitResult = $transform($split); +$splitMarkup = (string) ($splitResult['serialized_blocks'] ?? ''); +$assert(str_contains($splitMarkup, 'captured-dialog') && ! str_contains($splitMarkup, 'blocks-engine-menu-fold'), 'a row whose items are split by a control keeps the captured dialog', $splitMarkup); + +if ( 0 < $failures ) { + fwrite(STDERR, "Menu dialog fold contract: {$failures} failed, {$passes} passed\n"); + exit(1); +} +echo "Menu dialog fold contract passed: {$passes} assertions\n";