Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<int, DOMElement>
*/
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
Expand Down
22 changes: 22 additions & 0 deletions php-transformer/src/HtmlToBlocks/Style/CssValueInspector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> $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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<p>`
// or `<span>` inside it). Typography that carrier declares wins
Expand Down Expand Up @@ -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<DOMElement> */
Expand Down
78 changes: 78 additions & 0 deletions php-transformer/tests/unit/form-offscreen-submit-presenter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);

/**
* A hosted builder can move its native submit off-screen with inline styles
* and paint a captioned sibling in its place. The form layout and presentation
* graphs describe that visible sibling at the submit's control index, so a
* provider button reproduces the box a visitor sees instead of a 1px input.
*/

require dirname(__DIR__, 2) . '/vendor/autoload.php';

use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Classification\FormControlClassifier;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueInspector;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormLayoutGraphBuilder;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormPresentationGraphBuilder;

$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);
};

$css = '.btn { display: inline-block; background-color: #f5a623; padding: 0; } .btn .inner { color: #ffffff; font-family: Montserrat; font-weight: 700; }';
$hidden = 'position:absolute;top:0;left:-9999px;width:1px;height:1px';
$form = static function (string $submit, string $siblings) : DOMElement {
$document = new DOMDocument();
$document->loadHTML('<?xml encoding="utf-8" ?><body><form class="f" method="post"><input type="text" name="name"><div class="actions">' . $submit . $siblings . '</div></form></body>', LIBXML_NOERROR | LIBXML_NOWARNING);
return $document->getElementsByTagName('form')->item(0);
};
$presenter = '<a class="btn"><span class="inner">Send</span></a>';

// The off-screen submit is presented by its captioned sibling.
$source = $form('<input type="submit" style="' . $hidden . '" value="Send">', $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('<input type="submit" value="Send">', $presenter),
'clip-only hiding' => $form('<input type="submit" style="position:absolute;clip:rect(0 0 0 0)" value="Send">', $presenter),
'different caption' => $form('<input type="submit" style="' . $hidden . '" value="Send">', '<a class="btn">Cancel</a>'),
'two presenters' => $form('<input type="submit" style="' . $hidden . '" value="Send">', $presenter . $presenter),
'navigating link' => $form('<input type="submit" style="' . $hidden . '" value="Send">', '<a class="btn" href="/send">Send</a>'),
'non-submit control' => $form('<input type="text" name="other" style="' . $hidden . '" value="Send">', $presenter),
);
foreach ( $cases as $name => $case ) {
$assert(FormControlClassifier::controlElements($case) === FormControlClassifier::presentedControlElements($case), $name . ' paints itself');
}

$button = $form('<button type="submit" style="position:absolute; left:-10000px">Send</button>', '<span class="btn">Send</span>');
$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";
Loading