From c0b3e895eb2f4a5e710b9520805c3791aebc6c45 Mon Sep 17 00:00:00 2001 From: Ellen Bauer Date: Wed, 14 Oct 2026 12:49:14 +0700 Subject: [PATCH 1/2] fix(php-transformer): open a projected modal in its source open state Review of #2744 found that a projected captured modal could still open invisible or keep the browser's dialog box: - Id rules never matched the dialog during analysis. Its id is generated and the source panel id rides on its editor anchor class, so placement "source" missed a panel positioned only by id rules. The selector matcher and both candidate indexes now treat an element carrying an id's editor anchor class as that id's holder. - A rest rule that hides the panel (by id or by class) made the dialog hidden content and the dialog block was dropped. A captured dialog's own rest state no longer counts as hidden; the block owns its closed state. - Without a captured ancestor state, the rest rule won on the open dialog (`#panel{display:none;transform:translateX(-100%)}` against `body.nav-open #panel{display:block;transform:none}`). For each hiding or offsetting property that a state rule on the same panel reverses, the dialog now carries the state value inline. The block stylesheet's important closed-state rule still closes it. Refs #2726 Co-Authored-By: Claude Opus 5.5 --- php-transformer/composer.json | 1 + .../src/Css/CssSelectorMatchCache.php | 5 ++ .../src/Css/CssSelectorMatcher.php | 12 ++- .../Elements/CapturedDialogConverter.php | 29 +++++- .../src/HtmlToBlocks/HtmlCompilation.php | 9 +- .../Style/AuthorStyleAnalysis.php | 8 +- .../src/HtmlToBlocks/Style/StyleResolver.php | 89 ++++++++++++++++++ .../tests/unit/captured-dialog-open-state.php | 90 +++++++++++++++++++ 8 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 php-transformer/tests/unit/captured-dialog-open-state.php diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 865190ef6..7d93076c2 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -194,6 +194,7 @@ "php tests/unit/responsive-counterpart-contracts.php", "php tests/unit/captured-dialog-projector.php", "php tests/unit/captured-dialog-source-panel-box.php", + "php tests/unit/captured-dialog-open-state.php", "php tests/unit/captured-dialog-dropdown-panel.php", "php tests/unit/scoped-mixed-dropdown.php", "php tests/unit/capture-runtime-ancestor-parity.php", diff --git a/php-transformer/src/Css/CssSelectorMatchCache.php b/php-transformer/src/Css/CssSelectorMatchCache.php index 194fbf637..03c3fd84e 100644 --- a/php-transformer/src/Css/CssSelectorMatchCache.php +++ b/php-transformer/src/Css/CssSelectorMatchCache.php @@ -3,6 +3,7 @@ namespace Automattic\BlocksEngine\PhpTransformer\Css; +use Automattic\BlocksEngine\PhpTransformer\Support\EngineMarker; use DOMDocument; use DOMElement; use DOMNode; @@ -199,6 +200,10 @@ public function styleRuleCandidates(DOMElement $element, string $collection, arr } foreach ( $this->classTokens($element) as $class ) { $candidates = array_merge($candidates, $index['classes'][$class] ?? array()); + $anchoredId = EngineMarker::editorAnchorId($class); + if ( null !== $anchoredId && $anchoredId !== $id ) { + $candidates = array_merge($candidates, $index['ids'][$anchoredId] ?? array()); + } } $candidates = array_merge($candidates, $index['tags'][strtolower($element->tagName)] ?? array()); foreach ( $this->attributeNames($element) as $name ) { diff --git a/php-transformer/src/Css/CssSelectorMatcher.php b/php-transformer/src/Css/CssSelectorMatcher.php index aef894f1f..1de5e6d0d 100644 --- a/php-transformer/src/Css/CssSelectorMatcher.php +++ b/php-transformer/src/Css/CssSelectorMatcher.php @@ -5,6 +5,7 @@ use Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorMatchCache; use Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorTokenizer; +use Automattic\BlocksEngine\PhpTransformer\Support\EngineMarker; use DOMElement; use DOMNode; @@ -822,13 +823,20 @@ private static function matchesCompound(DOMElement $element, array $compound, ?C return false; } $actualId = array() !== $compound['ids'] ? (null !== $cache ? ($cache->attribute($element, 'id') ?? '') : $element->getAttribute('id')) : ''; + $classes = null; foreach ( $compound['ids'] as $id ) { if ( $actualId !== $id ) { - return false; + // An element that stands in for the id's holder carries the id + // as its editor anchor class (a projected captured dialog whose + // source panel id stays on the capture's hidden copy). + $classes ??= $cache?->classTokens($element) ?? (preg_split('/[\x09\x0A\x0C\x0D\x20]+/', trim($element->getAttribute('class'))) ?: array()); + if ( ! in_array(EngineMarker::editorAnchorClass($id), $classes, true) ) { + return false; + } } } if ( array() !== $compound['classes'] ) { - $classes = $cache?->classTokens($element) ?? (preg_split('/[\x09\x0A\x0C\x0D\x20]+/', trim($element->getAttribute('class'))) ?: array()); + $classes ??= $cache?->classTokens($element) ?? (preg_split('/[\x09\x0A\x0C\x0D\x20]+/', trim($element->getAttribute('class'))) ?: array()); foreach ( $compound['classes'] as $class ) { if ( ! in_array($class, $classes, true) ) { return false; diff --git a/php-transformer/src/HtmlToBlocks/Elements/CapturedDialogConverter.php b/php-transformer/src/HtmlToBlocks/Elements/CapturedDialogConverter.php index 63f37d197..af9d1954c 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/CapturedDialogConverter.php +++ b/php-transformer/src/HtmlToBlocks/Elements/CapturedDialogConverter.php @@ -18,11 +18,13 @@ final class CapturedDialogConverter implements ElementConverter /** * @param Closure(DOMElement, array>&): array> $convertChildren * @param (Closure(DOMElement): string)|null $resolvedStyle The element's authored, cascade-resolved declarations. + * @param (Closure(DOMElement): array)|null $openStateValues Values a state rule gives the panel over its hiding or offsetting rest rules. */ public function __construct( private readonly HtmlTransformerSession $session, private readonly Closure $convertChildren, - private readonly ?Closure $resolvedStyle = null + private readonly ?Closure $resolvedStyle = null, + private readonly ?Closure $openStateValues = null ) { } @@ -56,6 +58,29 @@ private function isSupersededByNativeNavigation(DOMElement $dialog): bool return false; } + /** + * The dialog's inline style, plus the open-state values a state rule gives + * the panel over rest rules that hide or offset it. The dialog is the + * captured open panel, so those rest rules must not win while it is open. + * Inline and without `!important`: it beats the rest rules, and the block + * stylesheet's `:not([open]){display:none!important}` still closes it. + */ + private function openSourceStyle(DOMElement $element): string + { + $style = trim(SourceDom::attr($element, 'style')); + if ( 'dialog' !== strtolower($element->tagName) || '' !== SourceDom::attr($element, 'data-blocks-engine-presentation') || null === $this->openStateValues ) { + return $style; + } + foreach ( ($this->openStateValues)($element) as $property => $value ) { + if ( 1 === preg_match('/(?:^|;)\s*' . preg_quote($property, '/') . '\s*:/i', $style) ) { + continue; + } + $style = rtrim($style, "; \t\n") . ('' === $style ? '' : ';') . $property . ':' . $value; + } + + return $style; + } + /** * A modal whose source panel positions itself (`position:fixed` or * `absolute` from its own author rules) drew its own box: the source panel @@ -85,7 +110,7 @@ public function block(DOMElement $element, array &$fallbacks): array $attrs = array_filter(array( 'tagName' => 'div' === strtolower($element->tagName) ? 'div' : '', - 'sourceStyle' => SourceDom::attr($element, 'style'), + 'sourceStyle' => $this->openSourceStyle($element), 'ancestorUnverified' => SourceDom::attr($element, CaptureEvidence::DIALOG_ANCESTOR_UNVERIFIED), 'dialogId' => trim(SourceDom::attr($element, 'id')), 'triggerIds' => array_values(array_filter(preg_split('/\s+/', trim(SourceDom::attr($element, 'data-blocks-engine-triggers'))) ?: array())), diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index 49acaa744..458d0b850 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -1032,7 +1032,8 @@ function (DOMElement $element, array &$fallbacks): ?array { function (DOMElement $element, array &$fallbacks) use ($convertChildren): array { return $convertChildren($element, $fallbacks, true); }, - fn (DOMElement $element): string => $this->styleResolver->specificityResolvedPresentationStyle($element) + fn (DOMElement $element): string => $this->styleResolver->specificityResolvedPresentationStyle($element), + fn (DOMElement $element): array => $this->styleResolver->capturedDialogOpenStateValues($element) ); $this->wrapperCoalescer = new WrapperCoalescer( $this->sourceElementClassifier, @@ -4263,6 +4264,12 @@ private function sourceElementStartsHidden(DOMElement $element): bool if ( SourceDom::documentVariantRoot($element) === $element ) { return false; } + // A projected captured dialog is the panel's open state. Its source + // rest rules hide it until script opens it; the dialog block owns that + // closed state, so they do not make the dialog hidden content. + if ( 'dialog' === strtolower($element->tagName) && 'true' === SourceDom::attr($element, 'data-blocks-engine-captured-dialog') ) { + return false; + } $declarations = $this->styleResolver->structuralPresentationDeclarations($element); $display = CssValueInspector::comparable((string) ($declarations['display'] ?? '')); $visibility = CssValueInspector::comparable((string) ($declarations['visibility'] ?? '')); diff --git a/php-transformer/src/HtmlToBlocks/Style/AuthorStyleAnalysis.php b/php-transformer/src/HtmlToBlocks/Style/AuthorStyleAnalysis.php index ce9c9f0c6..8408125b5 100644 --- a/php-transformer/src/HtmlToBlocks/Style/AuthorStyleAnalysis.php +++ b/php-transformer/src/HtmlToBlocks/Style/AuthorStyleAnalysis.php @@ -3,8 +3,8 @@ namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style; -use Automattic\BlocksEngine\PhpTransformer\Support\EngineMarker; use Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorMatchCache; +use Automattic\BlocksEngine\PhpTransformer\Support\EngineMarker; use Automattic\BlocksEngine\PhpTransformer\WordPress\SourceClassIdentity; use DOMElement; @@ -193,7 +193,9 @@ public function selectorCandidates(array $parsed): array $rightmost = $compounds[array_key_last($compounds)] ?? array(); $candidates = array(); foreach ( $rightmost['ids'] ?? array() as $id ) { - $candidates[] = $this->sourceElementsById[$id] ?? array(); + // A projected captured dialog stands in for the id's holder through + // its editor anchor class, so it is a candidate for the id too. + $candidates[] = array_merge($this->sourceElementsById[$id] ?? array(), $this->sourceElementsByClass[EngineMarker::editorAnchorClass((string) $id)] ?? array()); } foreach ( $rightmost['classes'] ?? array() as $class ) { $candidates[] = $this->sourceElementsByClass[$class] ?? array(); @@ -235,7 +237,7 @@ public function selectorCanMatch(array $parsed): bool return false; } foreach ( $compound['ids'] ?? array() as $id ) { - if ( ! isset($this->sourceIds[$id]) ) { + if ( ! isset($this->sourceIds[$id]) && ! isset($this->sourceElementsByClass[EngineMarker::editorAnchorClass((string) $id)]) ) { return false; } } diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php index c73af0715..a20a2d4bb 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php @@ -4715,6 +4715,95 @@ private function isSupportedCssSelector(string $selector): bool return (bool) ($this->context->parsedCssSelector($selector)['supported'] ?? false); } + /** + * The open-state values a captured modal dialog needs over its rest state. + * + * A projected dialog is the panel's captured open state. Its source panel + * is often hidden or slid away at rest (`#panel{display:none;transform: + * translateX(-100%)}`) and shown by a rule that only matches once script + * marks an ancestor (`body.nav-open #panel{display:block;transform:none}`). + * When that ancestor state was not captured, the rest rule wins on the + * open dialog and it opens invisible. For each hiding or offsetting + * property that a state rule on the same panel reverses, return the state + * rule's value, so the dialog can carry it while it is open. + * + * A state rule here is a static rule whose rightmost compound matches the + * dialog but whose full selector does not. + * + * @return array + */ + public function capturedDialogOpenStateValues(DOMElement $dialog): array + { + $hidden = array( + 'display' => static fn (string $value): bool => 'none' === $value, + 'visibility' => static fn (string $value): bool => 'hidden' === $value, + 'opacity' => static fn (string $value): bool => is_numeric($value) && 0.0 === (float) $value, + 'transform' => static fn (string $value): bool => ! self::isIdentityTransform($value), + ); + $rest = array(); + $state = array(); + $order = 0; + // Raw author rules: the static analysis keeps neither `transform` nor + // rules that match no source element, and a state rule matches none + // until script adds its ancestor class. + (new CssStylesheetTransformer())->visitStyleRules( + $this->context->authorStyles()->combinedCss(), + function (string $prelude, string $body, array $conditions) use ($dialog, $hidden, &$rest, &$state, &$order): void { + if ( array() !== $conditions ) { + return; + } + $declarations = array_intersect_key($this->cssDeclarations($body), $hidden); + if ( array() === $declarations ) { + return; + } + foreach ( CssStylesheetTransformer::splitSelectorList($prelude) ?? array() as $selector ) { + $selector = trim($selector); + $parsed = $this->context->parsedCssSelector($selector); + $span = $parsed['rightmost_compound_span'] ?? null; + if ( ! ($parsed['supported'] ?? false) || ! is_array($span) ) { + continue; + } + if ( $this->matchesCssSelector($dialog, $selector) ) { + $rest[] = array('specificity' => $this->mediaTextSelectorSpecificity($selector), 'order' => $order++, 'declarations' => $declarations); + continue; + } + $subject = substr($selector, (int) $span['start'], (int) $span['end'] - (int) $span['start']); + if ( 0 < (int) $span['start'] && $this->matchesCssSelector($dialog, $subject) ) { + $state[] = $declarations; + } + } + } + ); + usort($rest, static fn (array $left, array $right): int => array($left['specificity'], $left['order']) <=> array($right['specificity'], $right['order'])); + $resting = array(); + foreach ( $rest as $rule ) { + $resting = array_merge($resting, $rule['declarations']); + } + + $open = array(); + foreach ( $state as $declarations ) { + foreach ( $hidden as $property => $isHidden ) { + $restValue = CssValueInspector::comparable((string) ( $resting[$property] ?? '' )); + $stateValue = trim((string) preg_replace('/\s*!important\s*$/i', '', (string) ( $declarations[$property] ?? '' ))); + if ( '' !== $restValue && '' !== $stateValue && $isHidden($restValue) && ! $isHidden(CssValueInspector::comparable($stateValue)) ) { + $open[$property] = $stateValue; + } + } + } + + return $open; + } + + private static function isIdentityTransform(string $value): bool + { + $value = strtolower(trim($value)); + if ( '' === $value || 'none' === $value ) { + return true; + } + + return 1 === preg_match('/^(?:translate(?:x|y|z|3d)?\(\s*0(?:px|%)?\s*(?:,\s*0(?:px|%)?\s*){0,2}\)\s*)+$/', $value); + } + public function matchesCssSelector(DOMElement $element, string $selector): bool { $cache = $this->context->sourceStyles(); diff --git a/php-transformer/tests/unit/captured-dialog-open-state.php b/php-transformer/tests/unit/captured-dialog-open-state.php new file mode 100644 index 000000000..1933cf719 --- /dev/null +++ b/php-transformer/tests/unit/captured-dialog-open-state.php @@ -0,0 +1,90 @@ +` opening tag of the compiled dialog block, or ''. */ +$dialog = static function (string $css): string { + $panel = ''; + $wired = ''; + $html = '
Home' + . '' + . $wired . '

Body

'; + $state = array( + 'status' => 'captured', + 'trigger' => array('selector' => '#menu', 'tag' => 'button', 'label' => 'Open menu', 'ariaHaspopup' => 'dialog', 'dataBindings' => array()), + 'dialog' => array('selector' => '#panel', 'tag' => 'div', 'role' => 'dialog', 'presentation' => 'modal', 'html' => $panel, 'htmlBytes' => strlen($panel), 'htmlTruncated' => false), + ); + $artifact = array('entrypoint' => 'website/index.html', 'files' => array( + array('path' => 'website/index.html', 'content' => $html), + array('path' => 'capture-receipt.json', 'content' => json_encode(array('schema' => 'blocks-engine-capture/capture-receipt/v1', 'routes' => array(array('url' => 'https://example.test/', 'path' => 'website/index.html'))), JSON_UNESCAPED_SLASHES)), + array('path' => 'interaction-states.json', 'content' => json_encode(array('schema' => 'blocks-engine-capture/captured-interactions/v1', 'pages' => array(array('sourceUrl' => 'https://example.test/', 'states' => array($state)))), JSON_UNESCAPED_SLASHES)), + )); + $compiler = new ArtifactCompiler(); + $shared = $compiler->prepareShared($artifact); + $result = $compiler->compose($shared, $compiler->compilePreparedPages($shared, $compiler->preparePages($artifact, $shared)))->toArray(); + $all = stripslashes((string) json_encode($result, JSON_UNESCAPED_SLASHES)); + preg_match_all('/]*>/', $all, $matches); + foreach ( $matches[0] as $tag ) { + // The block's rendered dialog, not the projected source HTML. + if ( ! str_contains($tag, 'data-blocks-engine-captured-dialog=') ) { + return $tag; + } + } + + return ''; +}; + +$byId = $dialog('#panel{position:fixed;top:0;left:10px;width:200px;display:none;transform:translateX(-100%)}body.nav-open #panel{display:block;transform:none}'); +$assert('' !== $byId, 'a panel hidden at rest by an id rule still becomes a dialog block', $byId); +$assert(str_contains($byId, 'style="display:block;transform:none"'), 'the open dialog carries the state rule values over the id rest rule', $byId); +$assert(str_contains($byId, 'data-blocks-engine-placement="source"'), 'a panel positioned only by id rules places itself', $byId); + +$byClass = $dialog('.panel{position:fixed;display:none;opacity:0}body.nav-open .panel{display:flex;opacity:1}'); +$assert(str_contains($byClass, 'style="display:flex;opacity:1"'), 'the open dialog carries the state rule values over the class rest rule', $byClass); + +$positioned = $dialog('#panel{position:fixed;top:0;left:10px;width:200px}'); +$assert(str_contains($positioned, 'data-blocks-engine-placement="source"') && ! str_contains($positioned, 'style='), 'an id-positioned panel with no rest state places itself and gets no open values', $positioned); + +$centred = $dialog('.panel{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%)}body.nav-open .panel{transform:translate(-50%,-50%) scale(1)}'); +$assert(! str_contains($centred, 'transform:'), 'a transform the open state keeps is not treated as a rest offset', $centred); + +if ( 0 < $failures ) { + fwrite(STDERR, "Captured dialog open state: {$failures} failed, {$passes} passed\n"); + exit(1); +} + +echo "Captured dialog open state passed: {$passes} assertions\n"; From 59a9430c847e1f8c1ca6f9da11bab9a6689db1e1 Mon Sep 17 00:00:00 2001 From: Ellen Bauer Date: Wed, 14 Oct 2026 13:13:31 +0700 Subject: [PATCH 2/2] fix(php-transformer): trust only agreeing or recorded state rules for a modal's open values Review of c0b3e895: - The last state rule in source order won: `.dark-theme #panel{opacity:.5}` after `body.nav-open #panel{opacity:1}` gave the dialog `opacity:.5`, and `.no-js #panel{display:block}` replaced `display:flex`. When the capture recorded an opened ancestor class, only state rules naming it are trusted; otherwise every state rule must agree on a property, or nothing is carried. - A centring `translate(-50%,-50%)` counted as a rest offset, so `.no-js #panel{transform:none}` un-centred a modal that was fine on trunk. Only a translation of a full panel (100%) or viewport (100vw, 100vh) or more now counts as moving the panel out of view. - A dialog none of whose triggers can ever show (no viewport shows the trigger or an ancestor) is dropped again instead of surviving as an orphaned closed dialog. Refs #2726 Co-Authored-By: Claude Opus 5.5 --- .../src/HtmlToBlocks/HtmlCompilation.php | 36 +++++- .../src/HtmlToBlocks/Style/StyleResolver.php | 103 ++++++++++++++---- .../tests/unit/captured-dialog-open-state.php | 26 ++++- 3 files changed, 140 insertions(+), 25 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index 458d0b850..683b0ec42 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -4259,6 +4259,37 @@ private function withPropagatedWrapperTextAlignment(array $innerBlocks, DOMEleme return $innerBlocks; } + /** + * Whether any trigger of a captured dialog can show at some viewport: no + * element from the trigger up declares `display:none` at its base without + * a conditional value that shows it, and none carries `hidden`. + */ + private function capturedDialogHasReachableTrigger(DOMElement $dialog): bool + { + $document = $dialog->ownerDocument; + foreach ( preg_split('/\s+/', trim(SourceDom::attr($dialog, 'data-blocks-engine-triggers'))) ?: array() as $id ) { + $trigger = '' !== $id && null !== $document ? $document->getElementById($id) : null; + if ( ! $trigger instanceof DOMElement ) { + continue; + } + $reachable = true; + for ( $node = $trigger; $node instanceof DOMElement && 'body' !== strtolower($node->tagName); $node = $node->parentNode ) { + $display = $this->styleResolver->declaredPresentation($node, 'display'); + $base = CssValueInspector::comparable($display->base()); + $shownSomewhere = array() !== array_filter($display->conditional(), static fn (string $value): bool => 'none' !== CssValueInspector::comparable($value)); + if ( $node->hasAttribute('hidden') || ( 'none' === $base && ! $shownSomewhere ) ) { + $reachable = false; + break; + } + } + if ( $reachable ) { + return true; + } + } + + return false; + } + private function sourceElementStartsHidden(DOMElement $element): bool { if ( SourceDom::documentVariantRoot($element) === $element ) { @@ -4267,7 +4298,10 @@ private function sourceElementStartsHidden(DOMElement $element): bool // A projected captured dialog is the panel's open state. Its source // rest rules hide it until script opens it; the dialog block owns that // closed state, so they do not make the dialog hidden content. - if ( 'dialog' === strtolower($element->tagName) && 'true' === SourceDom::attr($element, 'data-blocks-engine-captured-dialog') ) { + // A dialog none of whose triggers can ever show is still dropped. + if ( 'dialog' === strtolower($element->tagName) && 'true' === SourceDom::attr($element, 'data-blocks-engine-captured-dialog') + && $this->capturedDialogHasReachableTrigger($element) + ) { return false; } $declarations = $this->styleResolver->structuralPresentationDeclarations($element); diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php index a20a2d4bb..37f92c517 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php @@ -4719,16 +4719,23 @@ private function isSupportedCssSelector(string $selector): bool * The open-state values a captured modal dialog needs over its rest state. * * A projected dialog is the panel's captured open state. Its source panel - * is often hidden or slid away at rest (`#panel{display:none;transform: - * translateX(-100%)}`) and shown by a rule that only matches once script - * marks an ancestor (`body.nav-open #panel{display:block;transform:none}`). - * When that ancestor state was not captured, the rest rule wins on the - * open dialog and it opens invisible. For each hiding or offsetting - * property that a state rule on the same panel reverses, return the state - * rule's value, so the dialog can carry it while it is open. + * is often hidden or slid out of view at rest (`#panel{display:none; + * transform:translateX(-100%)}`) and shown by a rule that only matches once + * script marks an ancestor (`body.nav-open #panel{display:block; + * transform:none}`). When that ancestor state was not captured, the rest + * rule wins on the open dialog and it opens invisible. * - * A state rule here is a static rule whose rightmost compound matches the - * dialog but whose full selector does not. + * A state rule is an unconditioned rule whose rightmost compound matches + * the dialog but whose full selector does not. Several can exist + * (`.dark-theme #panel`, `.no-js #panel`), so the one to trust is chosen: + * - when the capture recorded classes on an opened ancestor + * (`data-blocks-engine-ancestor-state`), only rules naming one of them + * are the open state (none naming one: nothing is carried); + * - otherwise every state rule is a candidate. + * A property is carried only when the rest cascade hides it, or moves the + * panel a full panel or viewport out of view, and every chosen candidate + * that declares it agrees on one value that shows it. Anything else is + * left to the source rules. * * @return array */ @@ -4738,8 +4745,9 @@ public function capturedDialogOpenStateValues(DOMElement $dialog): array 'display' => static fn (string $value): bool => 'none' === $value, 'visibility' => static fn (string $value): bool => 'hidden' === $value, 'opacity' => static fn (string $value): bool => is_numeric($value) && 0.0 === (float) $value, - 'transform' => static fn (string $value): bool => ! self::isIdentityTransform($value), + 'transform' => static fn (string $value): bool => self::isOutOfViewTransform($value), ); + $openedClasses = self::openedAncestorClasses($dialog); $rest = array(); $state = array(); $order = 0; @@ -4748,7 +4756,7 @@ public function capturedDialogOpenStateValues(DOMElement $dialog): array // until script adds its ancestor class. (new CssStylesheetTransformer())->visitStyleRules( $this->context->authorStyles()->combinedCss(), - function (string $prelude, string $body, array $conditions) use ($dialog, $hidden, &$rest, &$state, &$order): void { + function (string $prelude, string $body, array $conditions) use ($dialog, $hidden, $openedClasses, &$rest, &$state, &$order): void { if ( array() !== $conditions ) { return; } @@ -4769,7 +4777,11 @@ function (string $prelude, string $body, array $conditions) use ($dialog, $hidde } $subject = substr($selector, (int) $span['start'], (int) $span['end'] - (int) $span['start']); if ( 0 < (int) $span['start'] && $this->matchesCssSelector($dialog, $subject) ) { - $state[] = $declarations; + $context = substr($selector, 0, (int) $span['start']); + $state[] = array( + 'recorded' => array() !== array_filter($openedClasses, static fn (string $class): bool => 1 === preg_match('/\.' . preg_quote($class, '/') . '(?![\w-])/', $context)), + 'declarations' => $declarations, + ); } } } @@ -4779,29 +4791,76 @@ function (string $prelude, string $body, array $conditions) use ($dialog, $hidde foreach ( $rest as $rule ) { $resting = array_merge($resting, $rule['declarations']); } + // A recorded open state names the rules to trust; with none recorded, + // every state rule is a candidate and they must agree. + $candidates = array() !== $openedClasses + ? array_values(array_filter($state, static fn (array $rule): bool => $rule['recorded'])) + : $state; $open = array(); - foreach ( $state as $declarations ) { - foreach ( $hidden as $property => $isHidden ) { - $restValue = CssValueInspector::comparable((string) ( $resting[$property] ?? '' )); - $stateValue = trim((string) preg_replace('/\s*!important\s*$/i', '', (string) ( $declarations[$property] ?? '' ))); - if ( '' !== $restValue && '' !== $stateValue && $isHidden($restValue) && ! $isHidden(CssValueInspector::comparable($stateValue)) ) { - $open[$property] = $stateValue; + foreach ( $hidden as $property => $isHidden ) { + $restValue = CssValueInspector::comparable((string) ( $resting[$property] ?? '' )); + if ( '' === $restValue || ! $isHidden($restValue) ) { + continue; + } + $values = array(); + foreach ( $candidates as $rule ) { + $value = trim((string) preg_replace('/\s*!important\s*$/i', '', (string) ( $rule['declarations'][$property] ?? '' ))); + if ( '' !== $value ) { + $values[CssValueInspector::comparable($value)] = $value; } } + if ( 1 === count($values) && ! $isHidden((string) array_key_first($values)) ) { + $open[$property] = (string) reset($values); + } } return $open; } - private static function isIdentityTransform(string $value): bool + /** + * Class tokens the capture recorded on an ancestor only while the dialog + * was open (`data-blocks-engine-ancestor-state`). + * + * @return list + */ + private static function openedAncestorClasses(DOMElement $dialog): array + { + $rows = json_decode($dialog->getAttribute('data-blocks-engine-ancestor-state'), true); + $classes = array(); + foreach ( is_array($rows) ? $rows : array() as $row ) { + $opened = preg_split('/\s+/', trim((string) ( $row['opened']['class'] ?? '' ))) ?: array(); + $closed = preg_split('/\s+/', trim((string) ( $row['closed']['class'] ?? '' ))) ?: array(); + foreach ( array_diff($opened, $closed) as $class ) { + if ( 1 === preg_match('/^-?[_a-zA-Z][\w-]*$/', $class) ) { + $classes[] = $class; + } + } + } + + return array_values(array_unique($classes)); + } + + /** + * Whether a transform moves the panel out of view: a translation of a full + * panel size (100%) or a full viewport (100vw, 100vh) or more along an + * axis. A centring `translate(-50%,-50%)` or a small nudge keeps it in view. + */ + private static function isOutOfViewTransform(string $value): bool { $value = strtolower(trim($value)); - if ( '' === $value || 'none' === $value ) { - return true; + if ( 0 === preg_match_all('/\btranslate(?:x|y|3d)?\(([^)]*)\)/', $value, $calls) ) { + return false; + } + foreach ( $calls[1] as $arguments ) { + foreach ( preg_split('/\s*,\s*|\s+/', trim($arguments)) ?: array() as $argument ) { + if ( 1 === preg_match('/^-?(\d+(?:\.\d+)?)(%|vw|vh)$/', $argument, $length) && 100.0 <= (float) $length[1] ) { + return true; + } + } } - return 1 === preg_match('/^(?:translate(?:x|y|z|3d)?\(\s*0(?:px|%)?\s*(?:,\s*0(?:px|%)?\s*){0,2}\)\s*)+$/', $value); + return false; } public function matchesCssSelector(DOMElement $element, string $selector): bool diff --git a/php-transformer/tests/unit/captured-dialog-open-state.php b/php-transformer/tests/unit/captured-dialog-open-state.php index 1933cf719..68f9de426 100644 --- a/php-transformer/tests/unit/captured-dialog-open-state.php +++ b/php-transformer/tests/unit/captured-dialog-open-state.php @@ -18,6 +18,9 @@ * - a rest rule that hides the panel (by id or by class) made the dialog * hidden content, and the dialog block was dropped; * - without a captured ancestor state the rest rule won on the open dialog. + * State rules are trusted only when they agree, or when they name the opened + * ancestor class the capture recorded; only an out-of-view translate counts + * as a rest offset. */ require dirname(__DIR__, 2) . '/vendor/autoload.php'; @@ -36,12 +39,12 @@ }; /** The rendered `` opening tag of the compiled dialog block, or ''. */ -$dialog = static function (string $css): string { +$dialog = static function (string $css, string $triggerAttributes = ''): string { $panel = ''; $wired = ''; $html = '
Home' . '' + . ' data-blocks-engine-capture-dialog-trigger="wired" data-blocks-engine-capture-disclosure-label="Open menu"' . $triggerAttributes . '>' . $wired . '

Body

'; $state = array( 'status' => 'captured', @@ -82,6 +85,25 @@ $centred = $dialog('.panel{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%)}body.nav-open .panel{transform:translate(-50%,-50%) scale(1)}'); $assert(! str_contains($centred, 'transform:'), 'a transform the open state keeps is not treated as a rest offset', $centred); +$centredNoJs = $dialog('#panel{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%)}.no-js #panel{transform:none}'); +$assert(! str_contains($centredNoJs, 'style='), 'a centring translate is in view, so another state rule does not un-centre the modal', $centredNoJs); + +$theme = $dialog('#panel{opacity:0;position:fixed}body.nav-open #panel{opacity:1}.dark-theme #panel{opacity:.5}'); +$assert('' !== $theme && ! str_contains($theme, 'opacity:'), 'state rules that disagree carry nothing', $theme); + +$noJs = $dialog('#panel{display:none;position:fixed}body.nav-open #panel{display:flex}.no-js #panel{display:block}'); +$assert('' !== $noJs && ! str_contains($noJs, 'display:'), 'state rules that disagree on display carry nothing', $noJs); + +$recordedState = ' data-blocks-engine-capture-dialog-ancestor-state="' . htmlspecialchars((string) json_encode(array(array('tag' => 'body', 'closed' => array('class' => ''), 'opened' => array('class' => 'nav-open')))), ENT_QUOTES) . '"'; +$recorded = $dialog('#panel{display:none;position:fixed}body.nav-open #panel{display:flex}.no-js #panel{display:block}', $recordedState); +$assert(str_contains($recorded, 'style="display:flex"'), 'the state rule naming the recorded opened class wins over other state rules', $recorded); + +$unreachable = $dialog('#menu{display:none}#panel{display:none;position:fixed}body.nav-open #panel{display:block}'); +$assert('' === $unreachable, 'a dialog whose trigger never shows is still dropped', $unreachable); + +$phoneTrigger = $dialog('#menu{display:none}@media (max-width:767px){#menu{display:block}}#panel{display:none;position:fixed}body.nav-open #panel{display:block}'); +$assert(str_contains($phoneTrigger, 'style="display:block"'), 'a dialog whose trigger shows at some viewport is kept', $phoneTrigger); + if ( 0 < $failures ) { fwrite(STDERR, "Captured dialog open state: {$failures} failed, {$passes} passed\n"); exit(1);