Twenty-seven widgets announce themselves as buttons that a screen reader cannot press.
What is wrong
The app has one idiom for "a tappable thing that should read as a single announcement":
Semantics(
button: true,
label: someLabel,
excludeSemantics: true,
child: … InkWell(onTap: …),
)
excludeSemantics: true drops the descendants' semantics — which is the point, so the label is read once instead of the label and then each line inside it. But it drops the InkWell's tap action along with the text. What is left is a node flagged isButton carrying no SemanticsAction.tap.
VoiceOver and TalkBack announce it as a button. Activating it does nothing.
Measured, not assumed
Against the shared BorderedTapRow, with a real onTap:
ACTIONS: 0
FLAGS: 8 // isButton, and nothing else
The fix
Pass the handler to the Semantics as well as to the InkWell — onTap: onTap beside button: true. The tap keeps working for sighted users either way; this is what gives the node an action to carry.
Where excludeSemantics is itself conditional, the handler goes on only when the exclusion is active. Beside a live InkWell it makes the annotation a node boundary, splitting the row in two and putting the button flag on the node that does not carry the label.
Where
This listed nine files when it was written. The guard below found twenty-seven nodes, and three of the nine were the wrong file:
- the drill rows are
ReplayRow, not practice_drills_widget.dart
- the match tile is
match_tile.dart, not match_board_view.dart
path_module_section.dart has no button — its excluded node is a label rewrite, which is correct
lib/core/widgets/bordered_tap_row.dart is the shared one, so fixing it covers every caller. The rest are in PR #620.
term_of_day_banner.dart was already fixed (#96, PR #484) — it was the tenth, and shipping a new one knowingly broken was not an option. Its test shows the shape the assertion takes.
Acceptance
Twenty-seven widgets announce themselves as buttons that a screen reader cannot press.
What is wrong
The app has one idiom for "a tappable thing that should read as a single announcement":
excludeSemantics: truedrops the descendants' semantics — which is the point, so the label is read once instead of the label and then each line inside it. But it drops theInkWell's tap action along with the text. What is left is a node flaggedisButtoncarrying noSemanticsAction.tap.VoiceOver and TalkBack announce it as a button. Activating it does nothing.
Measured, not assumed
Against the shared
BorderedTapRow, with a realonTap:The fix
Pass the handler to the
Semanticsas well as to theInkWell—onTap: onTapbesidebutton: true. The tap keeps working for sighted users either way; this is what gives the node an action to carry.Where
excludeSemanticsis itself conditional, the handler goes on only when the exclusion is active. Beside a liveInkWellit makes the annotation a node boundary, splitting the row in two and putting the button flag on the node that does not carry the label.Where
This listed nine files when it was written. The guard below found twenty-seven nodes, and three of the nine were the wrong file:
ReplayRow, notpractice_drills_widget.dartmatch_tile.dart, notmatch_board_view.dartpath_module_section.darthas no button — its excluded node is a label rewrite, which is correctlib/core/widgets/bordered_tap_row.dartis the shared one, so fixing it covers every caller. The rest are in PR #620.term_of_day_banner.dartwas already fixed (#96, PR #484) — it was the tenth, and shipping a new one knowingly broken was not an option. Its test shows the shape the assertion takes.Acceptance
Semantics(button: true, excludeSemantics: true)node inlib/carries a tap action —test/unit/semantics_button_tap_guard_test.dartreads every source underlib/and fails listing any that does notBorderedTapRow, so the idiom cannot regress to the flag alone —test/widget/core/widgets/bordered_tap_row_test.dartperforms the announced action and checks the handler runs