Repository navigation
Open
Labels
P2Important issues not at the top of the work listImportant issues not at the top of the work lista: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)Accessibility, e.g. VoiceOver or TalkBack. (aka a11y)platform-webWeb applications specificallyWeb applications specificallyteam-webOwned by Web platform teamOwned by Web platform teamtriaged-webTriaged by Web platform teamTriaged by Web platform team
Description
Steps to reproduce
- Run the sample app on chrome browser
- Open the browser inspector and observe the accessibility
Expected results
- Text input has aria-label
- Submit button has an aria-label
- Dropdown2 with Semantics wrapper has an aria-label
Actual results
- Text input has aria-label
- Submit button does not have an aria-label. It does have a computed name but to meet accessibility requirements, client needs aria-label.
Wrapping the button in semantics (see Cancel button) applies an aria-label but we see a new child node
- Dropdown2 with Semantics wrapper has no aria-label
Code sample
Code sample
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
void main() {
runApp(const MyApp());
SemanticsBinding.instance.ensureSemantics();
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Sample Form',
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true),
home: const SampleFormPage(),
);
}
}
class SampleFormPage extends StatefulWidget {
const SampleFormPage({super.key});
@override
State<SampleFormPage> createState() => _SampleFormPageState();
}
class _SampleFormPageState extends State<SampleFormPage> {
final _formKey = GlobalKey<FormState>();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _emailController = TextEditingController();
String? _selectedCountry;
String? _selectedDepartment;
final ValueNotifier<String?> _departmentValue = ValueNotifier<String?>(null);
final List<String> _countries = ['India', 'United States', 'United Kingdom', 'Canada', 'Australia'];
final List<String> _departments = ['Engineering', 'Design', 'Product', 'Marketing', 'Sales'];
final Map<String, bool> _interests = {'Technology': false, 'Sports': false, 'Music': false, 'Travel': false};
String? _experience;
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_emailController.dispose();
_departmentValue.dispose();
super.dispose();
}
void _submitForm() {
if (!_formKey.currentState!.validate()) {
return;
}
final selectedInterests = _interests.entries.where((entry) => entry.value).map((entry) => entry.key).toList();
debugPrint('First Name: ${_firstNameController.text}');
debugPrint('Last Name: ${_lastNameController.text}');
debugPrint('Email: ${_emailController.text}');
debugPrint('Country: $_selectedCountry');
debugPrint('Department: $_selectedDepartment');
debugPrint('Interests: $selectedInterests');
debugPrint('Experience: $_experience');
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Form submitted successfully')));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample Flutter Form')),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 700),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Registration Form', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Please fill in the information below.'),
const SizedBox(height: 32),
// -------------------------------------------------------
// TEXT INPUTS
// -------------------------------------------------------
const Text('Personal Information', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
TextFormField(
controller: _firstNameController,
decoration: const InputDecoration(
labelText: 'First name',
hintText: 'Enter your first name',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter your first name';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _lastNameController,
decoration: const InputDecoration(
labelText: 'Last name',
hintText: 'Enter your last name',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter your last name';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'Enter your email address',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter your email';
}
if (!value.contains('@')) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 32),
// -------------------------------------------------------
// COMBOBOX
// -------------------------------------------------------
const Text('ComboBox', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Select your country.'),
const SizedBox(height: 16),
DropdownMenu<String>(
width: double.infinity,
label: const Text('Country'),
hintText: 'Select a country',
initialSelection: _selectedCountry,
dropdownMenuEntries: _countries
.map((country) => DropdownMenuEntry<String>(value: country, label: country))
.toList(),
onSelected: (value) {
setState(() {
_selectedCountry = value;
});
},
),
const SizedBox(height: 32),
// -------------------------------------------------------
// DROPDOWN
// -------------------------------------------------------
const Text('Dropdown', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _selectedDepartment,
decoration: const InputDecoration(labelText: 'Department', border: OutlineInputBorder()),
hint: const Text('Select a department sample hint'),
items: _departments
.map((department) => DropdownMenuItem<String>(value: department, child: Text(department)))
.toList(),
onChanged: (value) {
setState(() {
_selectedDepartment = value;
});
},
validator: (value) {
if (value == null) {
return 'Please select a department';
}
return null;
},
),
const SizedBox(height: 32),
// -------------------------------------------------------
// DROPDOWN WITH SEMANTIC WRAPPER
// -------------------------------------------------------
const Text(
'Dropdown with Semantics wrapper',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Semantics(
container: true,
child: DropdownButtonFormField<String>(
initialValue: _selectedDepartment,
decoration: const InputDecoration(labelText: 'Department', border: OutlineInputBorder()),
hint: const Text('Select a department sample hint'),
items: _departments
.map((department) => DropdownMenuItem<String>(value: department, child: Text(department)))
.toList(),
onChanged: (value) {
setState(() {
_selectedDepartment = value;
});
},
validator: (value) {
if (value == null) {
return 'Please select a department';
}
return null;
},
),
),
const SizedBox(height: 32),
// -------------------------------------------------------
// DROPDOWN 2 WITH SEMANTIC WRAPPER
// -------------------------------------------------------
const Text(
'Dropdown2 with Semantics wrapper',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Semantics(
container: true,
child: DropdownButtonFormField2<String>(
valueListenable: _departmentValue,
decoration: const InputDecoration(labelText: 'Department', border: OutlineInputBorder()),
hint: const Text('Select a department sample hint'),
items: _departments
.map((department) => DropdownItem<String>(value: department, child: Text(department)))
.toList(),
onChanged: (value) {
setState(() {
_selectedDepartment = value;
_departmentValue.value = value;
});
},
validator: (value) {
if (value == null) {
return 'Please select a department';
}
return null;
},
),
),
const SizedBox(height: 32),
// -------------------------------------------------------
// CHECKBOX GROUP
// -------------------------------------------------------
const Text('Interests', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Select all that apply.'),
const SizedBox(height: 8),
..._interests.keys.map((interest) {
return CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: Text(interest),
value: _interests[interest],
onChanged: (value) {
setState(() {
_interests[interest] = value ?? false;
});
},
);
}),
const SizedBox(height: 24),
// -------------------------------------------------------
// RADIO GROUP
// -------------------------------------------------------
const Text('Experience Level', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Select one option.'),
const SizedBox(height: 8),
Semantics(
container: true,
role: SemanticsRole.radioGroup,
label: 'Experience Level',
tooltip: 'Experience Level tooltip',
hint: 'Experience Level hint',
child: RadioGroup<String>(
groupValue: _experience,
onChanged: (value) {
setState(() {
_experience = value;
});
},
child: Column(
children: [
RadioListTile<String>(
contentPadding: EdgeInsets.zero,
title: const Text('Beginner'),
value: 'beginner',
),
RadioListTile<String>(
contentPadding: EdgeInsets.zero,
title: const Text('Intermediate'),
value: 'intermediate',
),
RadioListTile<String>(
contentPadding: EdgeInsets.zero,
title: const Text('Advanced'),
value: 'advanced',
),
],
),
),
),
const SizedBox(height: 32),
// -------------------------------------------------------
// Multiple buttons with same label
// -------------------------------------------------------
const Text('Bills Section', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Column(
children: [
Row(
children: [
Expanded(child: Text('Jan 2026 Bill \$200')),
Semantics(
container: true,
// excludeSemantics: true,
label: 'Pay Jan Bill',
button: true,
onTap: () => print('Paying bill fpr Jan'),
child: ElevatedButton(
onPressed: () => print('Paying bill for Jan'),
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text('Pay Bill'),
),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: Text('Feb 2026 Bill \$100')),
Semantics(
container: true,
// excludeSemantics: true,
label: 'Pay Feb Bill',
button: true,
onTap: () => print('Paying bill for Feb'),
child: ElevatedButton(
onPressed: () => print('Paying bill for Feb'),
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text('Pay Bill'),
),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: Text('Mar 2026 Bill \$50')),
Semantics(
container: true,
// excludeSemantics: true,
label: 'Pay Mar Bill',
button: true,
onTap: () => print('Paying bill for Mar'),
child: ElevatedButton(
onPressed: () => print('Paying bill for Mar'),
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text('Pay Bill'),
),
),
),
],
),
],
),
const SizedBox(height: 32),
// -------------------------------------------------------
// SUBMIT BUTTON
// -------------------------------------------------------
SizedBox(
width: double.infinity,
child: Semantics(
container: true,
// excludeSemantics: true,
label: 'Cancel',
button: true,
onTap: () => print('Cancelling'),
child: ElevatedButton(
onPressed: () => print('Cancelling'),
child: const Padding(padding: EdgeInsets.symmetric(vertical: 14), child: Text('Cancel')),
),
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submitForm,
child: const Padding(padding: EdgeInsets.symmetric(vertical: 14), child: Text('Submit All')),
),
),
],
),
),
),
),
),
);
}
}
Screenshots or Video
Screenshots / Video demonstration
[Upload media here]
Logs
Logs
[Paste your logs here]Flutter Doctor output
Doctor output
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.47.3, on macOS 26.5.2 25F84 darwin-arm64,
locale en-IN)
[✓] Android toolchain - develop for Android devices (Android SDK version
35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 26.6)
[✓] Chrome - develop for the web
[✓] Connected device (2 available)
! Error: Browsing on the local area network for iPhone 14. Ensure the
device is unlocked and attached with a cable or associated with the
same local area network as this Mac.
The device must be opted into Developer Mode to connect wirelessly.
(code -27)
! Error: Browsing on the local area network for iPhone 14(26). Ensure
the device is unlocked and attached with a cable or associated with
the same local area network as this Mac.
The device must be opted into Developer Mode to connect wirelessly.
(code -27)
! Error: Browsing on the local area network for iPhone 14 Plus. Ensure
the device is unlocked and attached with a cable or associated with
the same local area network as this Mac.
The device must be opted into Developer Mode to connect wirelessly.
(code -27)
[✓] Network resourcesMetadata
Metadata
Assignees
Labels
P2Important issues not at the top of the work listImportant issues not at the top of the work lista: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)Accessibility, e.g. VoiceOver or TalkBack. (aka a11y)platform-webWeb applications specificallyWeb applications specificallyteam-webOwned by Web platform teamOwned by Web platform teamtriaged-webTriaged by Web platform teamTriaged by Web platform team
Activity
suryaKommana2662 commentedon Sep 11, 2026
@Mickelle Thanks for the report. I tested the provided reproduction locally on Flutter Web using Chrome.
My observations from the Chrome DevTools Accessibility pane:
First name:
role: textbox, with an explicitaria-labelpresent (First name Enter your first name).Last name:
role: textbox, with an explicitaria-labelpresent (Last name Enter your last name).Email:
role: textbox, with an explicitaria-labelpresent (Email Enter your email address).Submit All:
role: button, accessible name isSubmit All, butaria-labelis not specified. The name is coming from the button contents.Dropdown2 with Semantics wrapper:
role: button, accessible name isDepartment Select a department sample hint, butaria-labelis not specified.Therefore, I was able to reproduce the reported missing explicit
aria-labelbehavior for the Submit All button and Dropdown2 with Semantics wrapper. The text input fields did expose anaria-labelas expected. Routing to theteam-accessibilityfor the further investigation.Flutter doctor Output
flutter-zl commentedon Sep 30, 2026
Hi @Mickelle , I can reproduce the issue you reported in Demo: https://flutter-demo-69-before.web.app/.
But it was designed like this.
Design history: buttons used to render aria-label. @yjbanov moved them to DOM text in flutter-team-archive/engine#50794 (2024, commit 8260746, fixing #122607) for two reasons:
JAWS FreedomScientific/standards-support#759.
Crawlers ignore aria-label.
Going back to aria-label alone would bring the JAWS bug back. Adding aria-label on top of the DOM text avoids that, but it has problems of its own:
Google Translate rewrites DOM text but not aria-label, so on a translated page the name read aloud would stay untranslated while the visible text changes.
It adds nothing for users, since the name is identical.
The same argument would then apply to links and headings.
I am wondering which accessibility tool requires the aria-label for these buttons?
Mickelle commentedon Oct 7, 2026
There is no specific tool that requires the aria-label. The voice over announcement is being done as expected by VoiceOver and NVDA.
The issue is that one of our clients requires the WCAG compliance to be followed.
We understand that the first rule of ARIA is not to use it unless we have to and this was a recommendation from one of our clients on remediating this issue by meeting the following requirement
Element should have one of the following:
mdebbar commentedon Oct 14, 2026
I'm not sure where these requirements are coming from. The W3C documentation specifically allows the name of a button to be derived from its contents: https://www.w3.org/TR/accname-1.2/#comp_name_from_content
And the screenshot you shared actually confirms what W3C says. You can see how it shows 4 options to derive the name from:
aria-labelled-by,aria-label,Contentsandtitle. In this case, it usedContentsas the name.Mickelle commentedon Oct 16, 2026
Thank you @mdebbar.
I have updated the sample code with the 'Billing Section' which is similar to the UI in my app. The screen has multiple buttons with the same label.
If there is more than one button with the exact same name, the use of an aria-label is recommended to provide a more descriptive name. This would allow screen reader user that are able to bring a list of buttons on a page to differentiate the one that they want to activate.