Repository: electron/electron
Pull request: fix: [a11y] fire AXMenuOpened event when ARIA menu is added to DOM

Backport of #50377

See that PR for details.


Notes: Fixed an accessibility issue where the AXMenuOpened event was not fired on menu creation.


Changed files:
filename: patches/chromium/.patches
patch: @@ -147,3 +147,4 @@ fix_update_dbus_signal_signature_for_xdg_globalshortcuts_portal.patch
 fix_set_correct_app_id_on_linux.patch
 fix_pass_trigger_for_global_shortcuts_on_wayland.patch
 feat_plumb_node_integration_in_worker_through_workersettings.patch
+fix_fire_menu_popup_start_for_dynamically_created_aria_menus.patch

filename: patches/chromium/fix_fire_menu_popup_start_for_dynamically_created_aria_menus.patch
patch: @@ -0,0 +1,95 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Keeley Hammond <khammond@slack-corp.com>
+Date: Thu, 19 Mar 2026 00:34:37 -0700
+Subject: fix: fire MENU_POPUP_START for dynamically created ARIA menus
+
+When an ARIA menu element is dynamically created (e.g. via appendChild)
+rather than being shown by toggling visibility, the AXMenuOpened event
+was not fired. The OnIgnoredChanged path handles the visibility toggle
+case, but OnAtomicUpdateFinished did not fire MENU_POPUP_START for
+newly created menu nodes.
+
+Previous attempts to fix this (crbug.com/1254875) were reverted because
+they fired the event too eagerly in OnNodeCreated (before the tree was
+fully formed) and without filtering, causing regressions with screen
+readers on pages that misused role="menu".
+
+This fix addresses both issues:
+1. Fires MENU_POPUP_START in OnAtomicUpdateFinished (after the tree
+   update is complete) rather than in OnNodeCreated.
+2. Only fires if the menu has at least one menuitem child, filtering
+   out false positives from misused role="menu" elements.
+
+MENU_POPUP_END for deleted menus is already handled by
+AXTreeManager::OnNodeWillBeDeleted, which fires the event directly
+on the menu node before destruction.
+
+The change is behind the DynamicMenuPopupEvents feature flag, disabled
+by default, to allow stabilization before enabling by default. Enable
+with --enable-features=DynamicMenuPopupEvents.
+
+This patch can be removed when a CL containing the fix is accepted
+into Chromium.
+
+Bug: 40794596
+
+diff --git a/ui/accessibility/ax_event_generator.cc b/ui/accessibility/ax_event_generator.cc
+index 5e0d7a48b4a039db67b5cc6b7e86103739702b40..517fb5e9904f3907de177e172c76328910bb7333 100644
+--- a/ui/accessibility/ax_event_generator.cc
++++ b/ui/accessibility/ax_event_generator.cc
+@@ -4,6 +4,7 @@
+ 
+ #include "ui/accessibility/ax_event_generator.h"
+ 
++#include "base/feature_list.h"
+ #include "base/no_destructor.h"
+ #include "ui/accessibility/ax_enums.mojom.h"
+ #include "ui/accessibility/ax_event.h"
+@@ -12,6 +13,12 @@
+ 
+ namespace ui {
+ 
++// Feature flag for firing MENU_POPUP_START for dynamically created ARIA menus.
++// Disabled by default to allow stabilization before enabling globally.
++BASE_FEATURE(kDynamicMenuPopupEvents,
++             "DynamicMenuPopupEvents",
++             base::FEATURE_DISABLED_BY_DEFAULT);
++
+ namespace {
+ 
+ bool HasEvent(const std::set<AXEventGenerator::EventParams>& node_events,
+@@ -914,12 +921,31 @@ void AXEventGenerator::OnAtomicUpdateFinished(
+                              /*new_value*/ true);
+     }
+ 
+-    if (IsAlert(change.node->GetRole()))
++    if (IsAlert(change.node->GetRole())) {
+       AddEvent(change.node, Event::ALERT);
+-    else if (change.node->data().IsActiveLiveRegionRoot())
++    } else if (change.node->data().IsActiveLiveRegionRoot()) {
+       AddEvent(change.node, Event::LIVE_REGION_CREATED);
+-    else if (change.node->data().IsContainedInActiveLiveRegion())
++    } else if (change.node->data().IsContainedInActiveLiveRegion()) {
+       FireLiveRegionEvents(change.node, /* is_removal */ false);
++    }
++
++    // Fire MENU_POPUP_START when a menu is dynamically created (e.g. via
++    // appendChild). The OnIgnoredChanged path handles menus that already exist
++    // in the DOM and are shown/hidden. This handles the case where the menu
++    // element itself is created on the fly.
++    // Only fire if the menu has at least one menuitem child, to avoid false
++    // positives from elements that misuse role="menu".
++    if (base::FeatureList::IsEnabled(kDynamicMenuPopupEvents) &&
++        change.node->GetRole() == ax::mojom::Role::kMenu &&
++        !change.node->IsInvisibleOrIgnored()) {
++      for (auto iter = change.node->UnignoredChildrenBegin();
++           iter != change.node->UnignoredChildrenEnd(); ++iter) {
++        if (IsMenuItem(iter->GetRole())) {
++          AddEvent(change.node, Event::MENU_POPUP_START);
++          break;
++        }
++      }
++    }
+   }
+ 
+   FireActiveDescendantEvents();