Skip to content

Commit b63678a

Browse files
committed
Fix hint and label contrast test failures by prioritizing them in InputDecorator hit testing
1 parent 70b3832 commit b63678a

4 files changed

Lines changed: 100 additions & 72 deletions

File tree

packages/flutter/lib/src/material/input_decorator.dart

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -756,18 +756,22 @@ class _RenderDecoration extends RenderBox
756756
RenderBox? get container => childForSlot(_DecorationSlot.container);
757757

758758
// The returned list is ordered for hit testing.
759+
// Prioritize hint when not focused to make it hit-testable for accessibility
760+
// guidelines.
759761
@override
760762
Iterable<RenderBox> get children {
761763
final RenderBox? helperError = childForSlot(_DecorationSlot.helperError);
762764
return <RenderBox>[
763765
?icon,
764-
?input,
766+
if (!isFocused) ?hint,
767+
if (!isFocused) ?label,
765768
?prefixIcon,
766769
?suffixIcon,
767770
?prefix,
768771
?suffix,
769-
?label,
770-
?hint,
772+
?input,
773+
if (isFocused) ?label,
774+
if (isFocused) ?hint,
771775
?helperError,
772776
?counter,
773777
?container,
@@ -1808,7 +1812,6 @@ class _AffixText extends StatelessWidget {
18081812
this.style,
18091813
this.child,
18101814
this.semanticsSortKey,
1811-
this.semanticsEnabled,
18121815
required this.semanticsTag,
18131816
});
18141817

@@ -1817,7 +1820,6 @@ class _AffixText extends StatelessWidget {
18171820
final TextStyle? style;
18181821
final Widget? child;
18191822
final SemanticsSortKey? semanticsSortKey;
1820-
final bool? semanticsEnabled;
18211823
final SemanticsTag semanticsTag;
18221824

18231825
@override
@@ -1833,7 +1835,6 @@ class _AffixText extends StatelessWidget {
18331835
child: Semantics(
18341836
sortKey: semanticsSortKey,
18351837
tagForChildren: semanticsTag,
1836-
enabled: semanticsEnabled,
18371838
child: child ?? (text == null ? null : Text(text!, style: style)),
18381839
),
18391840
),
@@ -2428,7 +2429,6 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
24282429
style: WidgetStateProperty.resolveAs(decoration.prefixStyle, widgetState) ?? hintStyle,
24292430
semanticsSortKey: needsSemanticsSortOrder ? _prefixSemanticsSortOrder : null,
24302431
semanticsTag: _kPrefixSemanticsTag,
2431-
semanticsEnabled: decoration.enabled,
24322432
child: decoration.prefix,
24332433
)
24342434
: null;
@@ -2440,7 +2440,6 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
24402440
style: WidgetStateProperty.resolveAs(decoration.suffixStyle, widgetState) ?? hintStyle,
24412441
semanticsSortKey: needsSemanticsSortOrder ? _suffixSemanticsSortOrder : null,
24422442
semanticsTag: _kSuffixSemanticsTag,
2443-
semanticsEnabled: decoration.enabled,
24442443
child: decoration.suffix,
24452444
)
24462445
: null;

packages/flutter/test/material/text_field_test.dart

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5653,12 +5653,7 @@ void main() {
56535653
children: <TestSemantics>[
56545654
TestSemantics(
56555655
children: <TestSemantics>[
5656-
TestSemantics(
5657-
id: 2,
5658-
textDirection: TextDirection.ltr,
5659-
flags: <SemanticsFlag>[SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled],
5660-
label: 'Prefix',
5661-
),
5656+
TestSemantics(id: 2, textDirection: TextDirection.ltr, label: 'Prefix'),
56625657
TestSemantics(
56635658
textDirection: TextDirection.ltr,
56645659
value: 'some text',
@@ -5672,12 +5667,7 @@ void main() {
56725667
SemanticsFlag.isEnabled,
56735668
],
56745669
),
5675-
TestSemantics(
5676-
id: 3,
5677-
textDirection: TextDirection.ltr,
5678-
flags: <SemanticsFlag>[SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled],
5679-
label: 'Suffix',
5680-
),
5670+
TestSemantics(id: 3, textDirection: TextDirection.ltr, label: 'Suffix'),
56815671
],
56825672
),
56835673
],

packages/flutter_test/lib/src/accessibility.dart

Lines changed: 2 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -335,53 +335,6 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
335335
return result;
336336
}
337337

338-
/// Determines if the given [element] is currently visible on screen and
339-
/// not occluded by other unrelated UI layers.
340-
///
341-
/// This method is similar to [Finder.hitTestable], as it performs a hit test
342-
/// at the center of the element. However, instead of strictly requiring the
343-
/// element's render object to be an ancestor of the hit target (which fails
344-
/// for siblings like `TextField` hints that delegate hits to other parts of
345-
/// the same component), it returns true if any render object in the hit
346-
/// path shares the same [debugSemantics] node. This allows testing
347-
/// non-interactive visual parts of interactive widgets while successfully
348-
/// rejecting elements truly occluded by completely separate overlapping layers.
349-
bool _isElementVisible(Element element, SemanticsNode node) {
350-
final RenderObject? object = element.renderObject;
351-
if (object is! RenderBox) {
352-
return false;
353-
}
354-
final int viewId = element.findAncestorWidgetOfExactType<View>()!.view.viewId;
355-
final Offset absoluteOffset = object.localToGlobal(object.size.center(Offset.zero));
356-
final hitResult = HitTestResult();
357-
WidgetsBinding.instance.hitTestInView(hitResult, absoluteOffset, viewId);
358-
359-
if (hitResult.path.isEmpty) {
360-
return false;
361-
}
362-
363-
// Find the first RenderObject in the hit path to ensure we respect occlusion
364-
// while properly handling non-RenderObject targets like TextSpan.
365-
RenderObject? topRenderObject;
366-
for (final HitTestEntry entry in hitResult.path) {
367-
if (entry.target is RenderObject) {
368-
topRenderObject = entry.target as RenderObject;
369-
break;
370-
}
371-
}
372-
373-
if (topRenderObject != null) {
374-
RenderObject? current = topRenderObject;
375-
while (current != null) {
376-
if (current == object || current.debugSemantics == node) {
377-
return true;
378-
}
379-
current = current.parent;
380-
}
381-
}
382-
return false;
383-
}
384-
385338
Future<Evaluation> _evaluateNode(
386339
SemanticsNode node,
387340
WidgetTester tester,
@@ -414,11 +367,9 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
414367
return result;
415368
}
416369
final String text = data.label.isEmpty ? data.value : data.label;
417-
final Iterable<Element> elements = find.text(text).evaluate();
370+
final Iterable<Element> elements = find.text(text).hitTestable().evaluate();
418371
for (final element in elements) {
419-
if (_isElementVisible(element, node)) {
420-
result += await _evaluateElement(node, element, tester, image, byteData, renderView);
421-
}
372+
result += await _evaluateElement(node, element, tester, image, byteData, renderView);
422373
}
423374
return result;
424375
}

packages/flutter_test/test/accessibility_test.dart

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,9 @@ void main() {
248248
handle.dispose();
249249
});
250250

251-
testWidgets('Material text field - hint text contrast', (WidgetTester tester) async {
251+
testWidgets('TextField hint text with insufficient contrast fails', (
252+
WidgetTester tester,
253+
) async {
252254
final SemanticsHandle handle = tester.ensureSemantics();
253255
await tester.pumpWidget(
254256
_boilerplate(
@@ -270,6 +272,91 @@ void main() {
270272
handle.dispose();
271273
});
272274

275+
testWidgets('TextField hint text with sufficient contrast passes', (WidgetTester tester) async {
276+
final SemanticsHandle handle = tester.ensureSemantics();
277+
await tester.pumpWidget(
278+
_boilerplate(
279+
const SizedBox(
280+
width: 200.0,
281+
child: TextField(decoration: InputDecoration(hintText: 'Enter text')),
282+
),
283+
),
284+
);
285+
await expectLater(tester, meetsGuideline(textContrastGuideline));
286+
handle.dispose();
287+
});
288+
289+
testWidgets('TextField label text with insufficient contrast fails', (
290+
WidgetTester tester,
291+
) async {
292+
final SemanticsHandle handle = tester.ensureSemantics();
293+
await tester.pumpWidget(
294+
_boilerplate(
295+
SizedBox(
296+
width: 200.0,
297+
child: TextField(
298+
decoration: InputDecoration(
299+
labelText: 'Email',
300+
labelStyle: TextStyle(color: Colors.white.withAlpha(32)),
301+
),
302+
),
303+
),
304+
),
305+
);
306+
await expectLater(tester, doesNotMeetGuideline(textContrastGuideline));
307+
handle.dispose();
308+
});
309+
310+
testWidgets('Disabled TextField hint text is excluded from contrast check', (
311+
WidgetTester tester,
312+
) async {
313+
final SemanticsHandle handle = tester.ensureSemantics();
314+
await tester.pumpWidget(
315+
_boilerplate(
316+
SizedBox(
317+
width: 200.0,
318+
child: TextField(
319+
enabled: false,
320+
decoration: InputDecoration(
321+
hintText: 'Enter text',
322+
hintStyle: TextStyle(color: Colors.white.withAlpha(32)),
323+
),
324+
),
325+
),
326+
),
327+
);
328+
await expectLater(tester, meetsGuideline(textContrastGuideline));
329+
handle.dispose();
330+
});
331+
332+
testWidgets('Text occluded by another widget is excluded from contrast check', (
333+
WidgetTester tester,
334+
) async {
335+
final SemanticsHandle handle = tester.ensureSemantics();
336+
await tester.pumpWidget(
337+
_boilerplate(
338+
SizedBox(
339+
width: 200.0,
340+
height: 200.0,
341+
child: Stack(
342+
children: <Widget>[
343+
// Text behind an opaque container — visually occluded.
344+
const Positioned.fill(
345+
child: Text(
346+
'hidden text',
347+
style: TextStyle(fontSize: 14.0, color: Colors.yellowAccent),
348+
),
349+
),
350+
Positioned.fill(child: Container(color: Colors.white)),
351+
],
352+
),
353+
),
354+
),
355+
);
356+
await expectLater(tester, meetsGuideline(textContrastGuideline));
357+
handle.dispose();
358+
});
359+
273360
testWidgets('Material2: yellow text on yellow background fails with correct message', (
274361
WidgetTester tester,
275362
) async {
@@ -453,6 +540,7 @@ void main() {
453540
handle.dispose();
454541
});
455542

543+
456544
testWidgets('Disabled button is excluded from text contrast guideline', (
457545
WidgetTester tester,
458546
) async {

0 commit comments

Comments
 (0)