Skip to content

[web] VoiceOver announces menu items with an off-by-one position/count (N-item menu reads as "2 of N+1") #190661

Closed

Description

@jimmy-tfp

Use case

On Flutter web, a popup menu's item positions are announced incorrectly by macOS VoiceOver in Chrome. A menu with 10 items announces the first item as "2 of 11" instead of "1 of 10" — every item's position and the total set size are shifted up by one.

This makes the position information VoiceOver reads to blind users wrong for every PopupMenuButton / showMenu (and MenuAnchor) on the web.

NVDA and JAWS on Windows announce the same menu correctly ("1 of 10").

Steps to reproduce

  1. Run the sample below on web: flutter run -d chrome.
  2. Enable VoiceOver on macOS (Cmd+F5).
  3. Focus the "Open menu" button and activate it.
  4. Arrow through the menu items and listen to the announced position.

Sample code

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: Home()));

class Home extends StatelessWidget {
  const Home({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: PopupMenuButton<int>(
          child: const Text('Open menu'),
          itemBuilder: (context) => <PopupMenuEntry<int>>[
            for (var i = 0; i < 10; i++)
              PopupMenuItem<int>(value: i, child: Text('Item ${i + 1}')),
          ],
        ),
      ),
    );
  }
}

Expected results

VoiceOver announces the first menu item as "Item 1, 1 of 10".

Actual results

VoiceOver announces the first menu item as "Item 1, 2 of 11" — position and set size are both off by one. The last item reads "11 of 11".

Root cause (framework source)

The framework wraps the menu items in a scrollable inside the role: SemanticsRole.menu node with explicitChildNodes: true. On web, the roled menuitem nodes are hoisted onto the menu via aria-owns, but the SingleChildScrollView remains as a direct child of the menu and is emitted as a role="group" node. macOS VoiceOver + Chrome then count that scroll-container group as an extra member of the menu (at position 1), shifting every real item by one.

Inspecting the live DOM confirms the structure — a 10-item menu produces:

<flt-semantics role="menu" aria-owns="[10 menuitem ids]">   ← correctly owns 10 items
   <flt-semantics role="group"> … 10 no-role visual placeholders … </flt-semantics>   ← the extra node

The role="menu" therefore has 11 members from VoiceOver's perspective: the scroll-container group + the 10 aria-owns'd menuitems. NVDA/JAWS honor aria-owns and count 10.

This wrapping is in the framework, e.g. _PopupMenu.build (packages/flutter/lib/src/material/popup_menu.dart, Flutter 3.44.2):

Semantics(
  role: SemanticsRole.menu,
  scopesRoute: true,
  namesRoute: true,
  explicitChildNodes: true,
  label: widget.semanticLabel,
  child: SingleChildScrollView(          // <-- becomes the extra role="group"
    padding: ...,
    child: ListBody(children: children),
  ),
)

MenuAnchor reproduces the same behavior — its _MenuPanel.build (packages/flutter/lib/src/material/menu_anchor.dart) also wraps the items in Scrollbar → SingleChildScrollView inside the menu, so switching from showMenu to MenuAnchor does not avoid the extra group / off-by-one.

Affected

  • Platform: Flutter web (CanvasKit and HTML/skwasm).
  • Screen reader: macOS VoiceOver (Chrome and Safari). NVDA/JAWS on Windows are correct.
  • Widgets: PopupMenuButton / showMenu and MenuAnchor (both go through a SingleChildScrollView inside the menu role).

flutter doctor / version

Flutter 3.44.2 • channel stable
Framework • revision c9a6c48423 • 2026-06-10
Engine • hash 04efd7c093d4e9281d5526ebcad6ecc60ba8badf
Tools • Dart 3.12.2 • DevTools 2.57.0

Activity

  1. puneetkukreja98 commented on Aug 7, 2026

    @puneetkukreja98
    Contributor

    @jimmy-tfp Thanks for reporting this issue, I was able to reproduce it on latest stable.

    Video:

    IMG_9940.MOV

    Flutter doctor -v:

    [✓] Flutter (Channel stable, 3.44.9, on macOS 26.4.1 25E253 darwin-arm64, locale en-US) [686ms]
        • Flutter version 3.44.9 on channel stable at /Users/puneetkukreja/Developer/flutter
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision 6b182d2c75 (2 days ago), 2026-08-05 10:04:07 -0700
        • Engine revision 5a2a6a42cc
        • Dart version 3.12.2
        • DevTools version 2.57.0
        • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, enable-swift-package-manager,
          omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration
    
    [✓] Android toolchain - develop for Android devices (Android SDK version 37.0.0-rc1) [3.0s]
        • Android SDK at /Users/puneetkukreja/Library/Android/sdk
        • Emulator version 36.6.11.0 (build_id 15507667) (CL:N/A)
        • Platform android-37.1, build-tools 37.0.0-rc1
        • Java binary at: /Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home/bin/java
          This JDK is specified in your Flutter configuration.
          To change the current JDK, run: `flutter config --jdk-dir="path/to/jdk"`.
        • Java version Java(TM) SE Runtime Environment (build 17.0.12+8-LTS-286)
        • All Android licenses accepted.
    
    [✓] Xcode - develop for iOS and macOS (Xcode 26.6) [1,349ms]
        • Xcode at /Applications/Xcode.app/Contents/Developer
        • Build 17F113
        • CocoaPods version 1.16.2
    
    [✓] Chrome - develop for the web [5ms]
        • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
    
    [✓] Connected device (3 available) [7.4s]
        • Puneet’s iPhone (wireless) (mobile) • 00008110-00167CEE0C46401E • ios            • iOS 18.0.1 22A3370
        • macOS (desktop)                     • macos                     • darwin-arm64   • macOS 26.4.1 25E253 darwin-arm64
        • Chrome (web)                        • chrome                    • web-javascript • Google Chrome 151.0.7922.76
    
    [✓] Network resources [994ms]
        • All expected network resources are available.
    
    • No issues found!
    
  2. added
    p: material_uimaterial_ui package in flutter/packages
    a: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)
    fyi-webFor the attention of Web platform team
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    frameworkflutter/packages/flutter repository. See also f: labels.
    platform-macosBuilding on or for macOS specifically
    and removed
    in triagePresently being triaged by the triage team
    on Aug 7, 2026
  3. added
    triaged-webTriaged by Web platform team
    P1High-priority issues at the top of the work list
    on Aug 12, 2026
  4. added
    packageflutter/packages repository. See also p: labels.
    and removed
    frameworkflutter/packages/flutter repository. See also f: labels.
    on Aug 17, 2026
  5. added
    team-webOwned by Web platform team
    and removed
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    on Aug 19, 2026
  6. chunhtai commented on Aug 19, 2026

    @chunhtai
    Contributor

    framework does not add the x of y string, it is automatically announced by the voiceover based on the dom tree

  7. yjbanov commented on Aug 26, 2026

    @yjbanov
    Contributor

    @chunhtai Do you know why the framework injects the intermediate node between the menu and the menu items? We could try applying something like role="presentation" in the engine, but the engine would need to know that this role needs to be applied.

  8. chunhtai commented on Aug 26, 2026

    @chunhtai
    Contributor

    I suspect this is for semantics grafting in overlayportal, but I am not too sure. However, why is an empty node still be counted by web? we do have empty container node all the time in framework

  9. yjbanov commented on Aug 26, 2026

    @yjbanov
    Contributor

    Empty container nodes are a common occurrence, but I guess when a node separates the menu node from menu items, screen readers count them. I wonder if we could set presentation for all nodes that are used purely for unlabeled grouping. This needs some investigation.

  10. github-actions commented on Oct 9, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  11. locked as resolved and limited conversation to collaborators on Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P1High-priority issues at the top of the work lista: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)p: material_uimaterial_ui package in flutter/packagespackageflutter/packages repository. See also p: labels.platform-macosBuilding on or for macOS specificallyteam-webOwned by Web platform teamtriaged-webTriaged by Web platform team

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions