Fix VoiceOver tab activation by adding tappable behavior to SemanticTab (#170076)

## Description
This pull request fixes VoiceOver tab activation by adding tappable
behavior to the SemanticTab class in the Flutter web engine. The fix
ensures that tabs can be properly activated using assistive technology
commands like VoiceOver's ctrl-option-space, making tab navigation fully
accessible for screen reader users.

## Before
When using VoiceOver to navigate tabs in a Flutter web app, users were
unable to activate tabs using the standard VoiceOver activation command
(ctrl-option-space). The SemanticTab class was missing the Tappable
semantic behavior that enables assistive technology interaction, causing
screen readers to treat tabs as non-interactive elements despite having
tap handlers in the Flutter framework.

**Before behavior:**
https://tab-0605-before.web.app/
- Navigate to a tab using VoiceOver (ctrl-option-arrow)
- Attempt to activate the tab with ctrl-option-space
- Tab does not respond to activation command
- Users cannot switch between tabs using assistive technology

## After
VoiceOver and other screen reader users can now properly activate tabs
using standard assistive technology commands. Tabs respond correctly to
ctrl-option-space and other activation gestures, providing full keyboard
accessibility for tab navigation.

**After behavior:**
https://tab-0605-after.web.app/
- Navigate to a tab using VoiceOver (ctrl-option-arrow)
- Activate the tab with ctrl-option-space
- Tab switches correctly, displaying the associated tab panel
- Consistent behavior across all assistive technologies

## Changes Made
- Added `addTappable()` call to `SemanticTab` constructor in `tabs.dart`
- Added test case "tab with tap action" to verify DOM elements receive
the `flt-tappable` attribute
- Ensures tabs with `hasTap: true` are properly marked as interactive
for assistive technologies

## Testing
Added unit test that verifies:
- Tabs with tap actions receive the `flt-tappable` DOM attribute
- SemanticTab properly integrates with the existing tappable behavior
system

## Issue Fixed
This PR addresses GitHub Issue #169279, which reports that VoiceOver
doesn't allow users to click tabs in Flutter web applications.


## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.
This commit is contained in:
zhongliugo 2025-06-06 15:15:18 -07:00 committed by GitHub
parent 8caedde6cc
commit ea83a6a072
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 21 additions and 0 deletions

View File

@ -19,6 +19,7 @@ class SemanticTab extends SemanticRole {
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
) {
setAriaRole('tab');
addTappable();
}
@override

View File

@ -4297,6 +4297,26 @@ void _testTabs() {
expect(object.element.getAttribute('role'), 'tab');
});
test('tab with tap action', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
role: ui.SemanticsRole.tab,
hasTap: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
final SemanticsObject object = tester.getSemanticsObject(0);
expect(object.semanticRole?.kind, EngineSemanticsRole.tab);
expect(object.element.getAttribute('role'), 'tab');
expect(object.element.hasAttribute('flt-tappable'), isTrue);
});
test('nodes with tab panel role', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)