diff --git a/php-transformer/composer.json b/php-transformer/composer.json index ebdba3b8d..6295acf0e 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -160,6 +160,7 @@ "php tests/unit/semantic-heading-brand.php", "php tests/unit/conditional-native-button.php", "php tests/unit/form-physical-box-ownership.php", + "php tests/unit/form-offscreen-submit-presenter.php", "php tests/unit/runtime-declarations-streaming-hash.php", "php tests/unit/external-metric-declarations.php", "php tests/unit/runtime-script-evidence-analyzer.php", diff --git a/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php b/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php index ebe9c690f..b771d7d0c 100644 --- a/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php +++ b/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php @@ -4,6 +4,8 @@ namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Classification; use Automattic\BlocksEngine\PhpTransformer\Contract\CaptureEvidence; +use Automattic\BlocksEngine\PhpTransformer\Css\CssRuleAnalyzer; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueInspector; use DOMElement; final class FormControlClassifier @@ -85,6 +87,65 @@ public static function controlElements(DOMElement $form): array return $controls; } + /** + * The shared control enumeration, with each control replaced by the element + * that paints it. Layout and presentation describe these boxes; control + * semantics stay on `controlElements()`, index for index. + * + * @return array + */ + public static function presentedControlElements(DOMElement $form): array + { + return array_map(self::presentingElement(...), self::controlElements($form)); + } + + /** + * The element that paints a native submit control. + * + * A site builder may move its native submit off-screen with inline styles + * and paint an adjacent sibling carrying the same caption in its place (a + * styled link with no destination). That sibling is the submit a visitor + * sees and the box a provider button reproduces. Any other control, or an + * ambiguous match, paints itself. + */ + public static function presentingElement(DOMElement $control): DOMElement + { + $parent = $control->parentNode; + if ( ! $parent instanceof DOMElement || ! in_array(self::controlType($control), array( 'submit', 'image' ), true) ) { + return $control; + } + $declarations = array_column(CssRuleAnalyzer::declarations($control->getAttribute('style'), array( 'position', 'left', 'top' )), 'value', 'name'); + if ( ! CssValueInspector::isMovedOffscreenBox($declarations) ) { + return $control; + } + $caption = self::normalizedCaption('input' === strtolower($control->tagName) ? ( '' !== trim($control->getAttribute('value')) ? $control->getAttribute('value') : $control->getAttribute('aria-label') ) : (string) $control->textContent); + if ( '' === $caption ) { + return $control; + } + $presenter = null; + foreach ( $parent->childNodes as $sibling ) { + if ( ! $sibling instanceof DOMElement || $sibling === $control || $sibling->hasAttribute('href') || self::isControlElement($sibling) || self::normalizedCaption((string) $sibling->textContent) !== $caption ) { + continue; + } + foreach ( self::CONTROL_TAGS as $tag ) { + if ( 0 < $sibling->getElementsByTagName($tag)->length ) { + continue 2; + } + } + if ( null !== $presenter ) { + return $control; + } + $presenter = $sibling; + } + + return $presenter ?? $control; + } + + private static function normalizedCaption(string $text): string + { + return strtolower(trim(preg_replace('/\s+/u', ' ', $text) ?? $text)); + } + /** * Hidden honeypots, template scaffolding, and accessibility-hidden traps * are not authored fields. aria-hidden outside a native form is closed diff --git a/php-transformer/src/HtmlToBlocks/Style/CssValueInspector.php b/php-transformer/src/HtmlToBlocks/Style/CssValueInspector.php index 9b7232211..e421f9931 100644 --- a/php-transformer/src/HtmlToBlocks/Style/CssValueInspector.php +++ b/php-transformer/src/HtmlToBlocks/Style/CssValueInspector.php @@ -204,6 +204,28 @@ public static function hidesText(string $property, string $value): bool return false; } + /** + * Whether a declaration set moves its box out of sight: an absolutely + * positioned box shifted by a negative `left` or `top` offset of at least + * 999px, the same off-screen threshold `hidesText()` applies to + * `text-indent`. + * + * @param array $declarations + */ + public static function isMovedOffscreenBox(array $declarations): bool + { + if ( ! in_array(self::comparable((string) ($declarations['position'] ?? '')), array( 'absolute', 'fixed' ), true) ) { + return false; + } + foreach ( array( 'left', 'top' ) as $property ) { + if ( 1 === preg_match('/^-(\d+(?:\.\d+)?)(?:px)?$/D', self::comparable((string) ($declarations[$property] ?? '')), $match) && (float) $match[1] >= 999 ) { + return true; + } + } + + return false; + } + /** * Whether a declaration set clips its box out of sight: the screen-reader * text pattern (an absolutely positioned box of at most 1px, overflow diff --git a/php-transformer/src/HtmlToBlocks/Style/FormLayoutGraphBuilder.php b/php-transformer/src/HtmlToBlocks/Style/FormLayoutGraphBuilder.php index 61da1aeb9..a60d99dae 100644 --- a/php-transformer/src/HtmlToBlocks/Style/FormLayoutGraphBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Style/FormLayoutGraphBuilder.php @@ -445,8 +445,9 @@ private function controls(DOMElement $form): array { // One shared enumeration keeps `control-N` graph ids aligned with the // reported controls, and keeps non-authored controls (and the wrappers - // that exist only to hide them) out of the projected layout. - return FormControlClassifier::controlElements($form); + // that exist only to hide them) out of the projected layout. Each id + // describes the box that paints its control. + return FormControlClassifier::presentedControlElements($form); } /** diff --git a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php index 2fe437082..83e07aa18 100644 --- a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php @@ -490,7 +490,8 @@ function (array $selector) use ($controlsForCustomProperties): bool { foreach (FormCustomPropertyResolver::conditionsChanging($fact['value'], $element, $customPropertyAnalysis['rules'], true) as $condition) $conditional[json_encode($condition)][$property] ??= $fact; } $carried = array(); - $buttonCaption = 'control' === $role && 'button' === strtolower($control->tagName); + // A button, or the element painting a hidden native submit, carries its caption. + $buttonCaption = 'control' === $role && ( 'button' === strtolower($control->tagName) || ! FormControlClassifier::isControlElement($control) ); if ( 'label' === $role || $buttonCaption ) { // The label's text is painted by its sole text carrier (a `

` // or `` inside it). Typography that carrier declares wins @@ -891,8 +892,9 @@ private function controls(DOMElement $form): array // One shared enumeration keeps presentation-graph indexes aligned with // reported controls. A raw tag walk counted aria-hidden honeypots the // control manifest omits, so layered author CSS on the submit button - // landed on an unmapped index and providers declined the form. - return FormControlClassifier::controlElements($form); + // landed on an unmapped index and providers declined the form. Each + // index describes the element that paints its control. + return FormControlClassifier::presentedControlElements($form); } /** @return list */ diff --git a/php-transformer/tests/unit/form-offscreen-submit-presenter.php b/php-transformer/tests/unit/form-offscreen-submit-presenter.php new file mode 100644 index 000000000..06c299669 --- /dev/null +++ b/php-transformer/tests/unit/form-offscreen-submit-presenter.php @@ -0,0 +1,78 @@ +loadHTML('

' . $submit . $siblings . '
', LIBXML_NOERROR | LIBXML_NOWARNING); + return $document->getElementsByTagName('form')->item(0); +}; +$presenter = 'Send'; + +// The off-screen submit is presented by its captioned sibling. +$source = $form('', $presenter); +$controls = FormControlClassifier::controlElements($source); +$presented = FormControlClassifier::presentedControlElements($source); +$assert(2 === count($controls) && 'input' === $controls[1]->tagName, 'control semantics keep the native submit', (string) count($controls)); +$assert(2 === count($presented) && 'a' === $presented[1]->tagName && $presented[0] === $controls[0], 'presentation describes the visible sibling at the same index'); + +$layout = array_column((new FormLayoutGraphBuilder())->build($source, array(), $css)['nodes'], null, 'id'); +$assert('a' === ($layout['control-1']['source']['tag'] ?? null) && 'inline-block' === ($layout['control-1']['layout']['display'] ?? null), 'the layout graph sizes the presenter box', json_encode($layout['control-1'] ?? null)); + +$presentation = (new FormPresentationGraphBuilder())->build($source, array(), $css); +$styles = array_column($presentation['controls'], null, 'index')[1]['control']['styles'] ?? array(); +$assert('#f5a623' === ($styles['background_color'] ?? null), 'the presenter paint reaches the submit', json_encode($styles)); +$assert('#ffffff' === ($styles['color'] ?? null) && 'Montserrat' === ($styles['font_family'] ?? null) && '700' === ($styles['font_weight'] ?? null), 'the presenter caption typography reaches the submit', json_encode($styles)); +$assert(! isset($styles['left']) && ! isset($styles['width']), 'the hidden input geometry stays off the submit', json_encode($styles)); + +// A visible submit, a captionless match, an ambiguous match, and a real link all keep the native control. +$cases = array( + 'visible submit' => $form('', $presenter), + 'clip-only hiding' => $form('', $presenter), + 'different caption' => $form('', 'Cancel'), + 'two presenters' => $form('', $presenter . $presenter), + 'navigating link' => $form('', 'Send'), + 'non-submit control' => $form('', $presenter), +); +foreach ( $cases as $name => $case ) { + $assert(FormControlClassifier::controlElements($case) === FormControlClassifier::presentedControlElements($case), $name . ' paints itself'); +} + +$button = $form('', 'Send'); +$assert('span' === (FormControlClassifier::presentedControlElements($button)[1]->tagName ?? null), 'a hidden button is presented by its captioned sibling'); + +$assert(CssValueInspector::isMovedOffscreenBox(array( 'position' => 'absolute', 'top' => '-9999px' )), 'a negative top offset moves the box off-screen'); +$assert(! CssValueInspector::isMovedOffscreenBox(array( 'position' => 'relative', 'left' => '-9999px' )), 'a relative box stays in flow'); +$assert(! CssValueInspector::isMovedOffscreenBox(array( 'position' => 'absolute', 'left' => '-20px' )), 'a small negative offset is still on-screen'); + +if ( 0 < $failures ) { + fwrite(STDERR, "form off-screen submit presenter FAILED: {$passes} passed, {$failures} failed\n"); + exit(1); +} +echo "form off-screen submit presenter passed: {$passes} assertions\n";