diff --git a/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php b/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php
index 494cc7792..164e38fdf 100644
--- a/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php
+++ b/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php
@@ -212,8 +212,9 @@ public static function isNonAuthoredControl(DOMElement $control): bool
}
/**
- * An untabbable free-text field is a trap when aria-hidden on itself, or
- * invisible and non-interactive on itself or its immediate field container.
+ * An untabbable free-text field is a trap when aria-hidden on itself or on
+ * an ancestor inside its form, or invisible and non-interactive on itself
+ * or its immediate field container.
* Choice and select value carriers share those attributes beside their
* visible widget, so only free-text entry qualifies.
*/
@@ -229,7 +230,8 @@ private static function isUnreachableTextTrap(DOMElement $control): bool
|| ! in_array(self::controlType($control), array( 'text', 'email', 'url', 'tel', 'search', 'number' ), true) ) ) {
return false;
}
- if ( 'true' === strtolower(trim($control->getAttribute('aria-hidden'))) ) {
+ if ( 'true' === strtolower(trim($control->getAttribute('aria-hidden')))
+ || null !== self::ariaHiddenFormAncestor($control) ) {
return true;
}
foreach ( array( $control, $control->parentNode ) as $element ) {
@@ -251,6 +253,26 @@ private static function isUnreachableTextTrap(DOMElement $control): bool
return false;
}
+ /**
+ * The outermost `aria-hidden="true"` ancestor between a control and its
+ * native form, or null when the control sits outside a form or no
+ * ancestor below the form is hidden from assistive technology.
+ */
+ private static function ariaHiddenFormAncestor(DOMElement $control): ?DOMElement
+ {
+ $hidden = null;
+ for ( $parent = $control->parentNode; $parent instanceof DOMElement; $parent = $parent->parentNode ) {
+ if ( 'form' === strtolower($parent->tagName) ) {
+ return $hidden;
+ }
+ if ( 'true' === strtolower(trim($parent->getAttribute('aria-hidden'))) ) {
+ $hidden = $parent;
+ }
+ }
+
+ return null;
+ }
+
/**
* A decoy field bots fill and no reader sees: `aria-hidden="true"`, out of
* the tab order, and hidden or moved off-screen by inline style — all
@@ -268,9 +290,11 @@ public static function isHoneypotControl(DOMElement $control): bool
/**
* A trap owns its associated/wrapping label and its immediate field
- * container's copy. Never claim a form/group or a container shared with
- * a real control, and count raw controls here (authored enumeration has
- * already dropped the traps).
+ * container's copy. A trap hidden by an aria-hidden ancestor inside its
+ * form also owns every container up to and including that ancestor.
+ * Never claim a form/group or a container shared with a real control,
+ * and count raw controls here (authored enumeration has already dropped
+ * the traps).
*
* @return array
*/
@@ -285,22 +309,36 @@ public static function honeypotCopyElements(DOMElement $form): array
if ( null !== $label ) {
$elements[] = $label;
}
- $container = $control->parentNode;
- if ( ! $container instanceof DOMElement
- || in_array(strtolower($container->tagName), array( 'form', 'fieldset', 'body', 'html' ), true) ) {
- continue;
- }
- foreach ( $container->getElementsByTagName('*') as $sibling ) {
- if ( $sibling instanceof DOMElement && self::isControlElement($sibling) && ! self::isHoneypotControl($sibling) ) {
- continue 2;
+ $hiddenAncestor = self::isUnreachableTextTrap($control) ? self::ariaHiddenFormAncestor($control) : null;
+ for ( $container = $control->parentNode; $container instanceof DOMElement; $container = $container->parentNode ) {
+ if ( in_array(strtolower($container->tagName), array( 'form', 'fieldset', 'body', 'html' ), true)
+ || self::holdsAuthoredControl($container) ) {
+ break;
+ }
+ if ( ! in_array($container, $elements, true) ) {
+ $elements[] = $container;
+ }
+ if ( null === $hiddenAncestor || $container === $hiddenAncestor ) {
+ break;
}
}
- $elements[] = $container;
}
return $elements;
}
+ /** Whether a container holds a raw control that is not a honeypot. */
+ private static function holdsAuthoredControl(DOMElement $container): bool
+ {
+ foreach ( $container->getElementsByTagName('*') as $element ) {
+ if ( $element instanceof DOMElement && self::isControlElement($element) && ! self::isHoneypotControl($element) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/** The control's wrapping label when that label, not the control, carries the honeypot signals. */
private static function honeypotSignalLabel(DOMElement $control): ?DOMElement
{
diff --git a/php-transformer/tests/unit/invisible-unreachable-text-trap.php b/php-transformer/tests/unit/invisible-unreachable-text-trap.php
index a9cec4378..f41ea7e8f 100644
--- a/php-transformer/tests/unit/invisible-unreachable-text-trap.php
+++ b/php-transformer/tests/unit/invisible-unreachable-text-trap.php
@@ -3,6 +3,7 @@
require dirname(__DIR__, 2) . '/vendor/autoload.php';
+use Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler\ArtifactCompiler;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Classification\FormControlClassifier;
$failures = 0;
@@ -64,5 +65,56 @@
$assert(1 === count(FormControlClassifier::controlElements($parse($html))), $boundary . ' is not a field container signal');
}
+// An untabbable free-text trap hidden by an aria-hidden wrapper inside its
+// form: the wrapper, its label, and the control stay out of readable blocks.
+$wrapper = '
'
+ . '
';
+$contact = '';
+
+$form = $parse($contact);
+$names = array_map(static fn (DOMElement $control): string => $control->getAttribute('name'), FormControlClassifier::controlElements($form));
+$assert(! in_array('hp', $names, true) && array( 'name', 'email', 'message' ) === array_values(array_filter($names)), 'aria-hidden ancestor trap is not an authored control', json_encode($names));
+$copy = FormControlClassifier::honeypotCopyElements($form);
+$assert(in_array($form->getElementsByTagName('label')->item(3), $copy, true), 'aria-hidden ancestor trap owns its label');
+$assert(in_array($form->getElementsByTagName('div')->item(0), $copy, true), 'aria-hidden ancestor trap owns its trap-only wrapper');
+
+$result = ( new ArtifactCompiler() )->compile(array(
+ 'entrypoint' => 'index.html',
+ 'files' => array( 'index.html' => $contact ),
+))->toArray();
+$readable = $result['fallbacks'][0]['readable_blocks'] ?? array();
+$assert(array() !== $readable, 'contact form yields readable blocks');
+$blockNames = array();
+$emptyGroups = 0;
+$walk = static function (array $blocks) use (&$walk, &$blockNames, &$emptyGroups): void {
+ foreach ( $blocks as $block ) {
+ $blockNames[] = $block['blockName'] ?? null;
+ $inner = $block['innerBlocks'] ?? array();
+ if ( 'core/group' === ( $block['blockName'] ?? null ) && array() === $inner ) {
+ ++$emptyGroups;
+ }
+ $walk($inner);
+ }
+};
+$walk($readable);
+$assert(! in_array('core/html', $blockNames, true), 'aria-hidden wrapper trap leaves no core/html in readable blocks', json_encode($blockNames));
+$assert(! str_contains((string) json_encode($readable), 'Leave empty'), 'no trap label text reaches readable blocks');
+$assert(1 === count(array_keys($blockNames, 'core/group', true)) && 0 === $emptyGroups, 'the trap wrapper container is gone from readable blocks', json_encode($blockNames));
+$layoutGraph = (string) json_encode($result['fallbacks'][0]['layout_graph'] ?? array());
+$assert(! str_contains($layoutGraph, 'Leave empty') && ! str_contains($layoutGraph, '"hp"'), 'no trap markup reaches the form layout graph');
+
+// Choice carriers under the same aria-hidden wrapper keep their behaviour.
+$form = $parse('');
+$assert(array() === FormControlClassifier::honeypotCopyElements($form), 'aria-hidden wrapper does not make a choice carrier a trap');
+
+// The ancestor walk is bounded by the form.
+$form = $parse('');
+$assert(array() === FormControlClassifier::honeypotCopyElements($form), 'aria-hidden outside the form does not make a text trap');
+
fwrite(STDOUT, "invisible unreachable text trap: {$failures} failures\n");
exit($failures > 0 ? 1 : 0);