You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by da...@apache.org on 2026/08/23 09:16:40 UTC

(causeway) 05/22: fixes application menu dismissal

This is an automated email from the ASF dual-hosted git repository.

danhaywood pushed a commit to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git

commit da9fba4475928d3724f9341b1f841d38cbbb1ba8
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Thu Aug 20 20:53:41 2026 +0100

    fixes application menu dismissal
---
 openspec/changes/fix-menu-dismissal/tasks.md          | 14 +++++++-------
 .../webcomponents/foundation/src/menubar-element.mjs  |  8 +++++---
 .../foundation/test/menubar-component.test.mjs        | 19 ++++++++++++++++---
 .../htmx/petclinic/PetClinicHtmxPlaywrightTest.java   |  7 +++++++
 4 files changed, 35 insertions(+), 13 deletions(-)

diff --git a/openspec/changes/fix-menu-dismissal/tasks.md b/openspec/changes/fix-menu-dismissal/tasks.md
index 9b105577620..6f8ff141876 100644
--- a/openspec/changes/fix-menu-dismissal/tasks.md
+++ b/openspec/changes/fix-menu-dismissal/tasks.md
@@ -1,15 +1,15 @@
 ## 1. Regression Coverage
 
-- [ ] 1.1 Extend the foundation menubar component test to reproduce action-selection and Escape dismissal, including `aria-expanded`, panel `hidden`, action dispatch count, prevented Escape, and disclosure focus assertions.
-- [ ] 1.2 Extend the Petclinic Playwright acceptance test to verify that `Pet Owners > Create` closes the menu before the prompt is used and that Escape closes an open menu without invoking an action.
+- [x] 1.1 Extend the foundation menubar component test to reproduce action-selection and Escape dismissal, including `aria-expanded`, panel `hidden`, action dispatch count, prevented Escape, and disclosure focus assertions.
+- [x] 1.2 Extend the Petclinic Playwright acceptance test to verify that `Pet Owners > Create` closes the menu before the prompt is used and that Escape closes an open menu without invoking an action.
 
 ## 2. Menu Dismissal Behavior
 
-- [ ] 2.1 Update `CausewayMenubarElement` so enabled service-action selection closes the containing menu through the shared disclosure-state path while dispatching the semantic action request exactly once.
-- [ ] 2.2 Ensure Escape gives an expanded menu priority, closes only that menu panel, restores focus to its disclosure, and leaves responsive bar state unchanged.
-- [ ] 2.3 Confirm prompt, scalar result, collection result, and route-navigation focus hand-offs continue to override disclosure fallback focus where appropriate.
+- [x] 2.1 Update `CausewayMenubarElement` so enabled service-action selection closes the containing menu through the shared disclosure-state path while dispatching the semantic action request exactly once.
+- [x] 2.2 Ensure Escape gives an expanded menu priority, closes only that menu panel, restores focus to its disclosure, and leaves responsive bar state unchanged.
+- [x] 2.3 Confirm prompt, scalar result, collection result, and route-navigation focus hand-offs continue to override disclosure fallback focus where appropriate.
 
 ## 3. Verification
 
-- [ ] 3.1 Run the foundation JavaScript test suite and confirm all menubar and interaction tests pass.
-- [ ] 3.2 Run the HTMX Petclinic unit and opt-in Playwright acceptance tests and confirm menu dismissal introduces no browser, GraphQL, focus, or navigation failures.
+- [x] 3.1 Run the foundation JavaScript test suite and confirm all menubar and interaction tests pass.
+- [x] 3.2 Run the HTMX Petclinic unit and opt-in Playwright acceptance tests and confirm menu dismissal introduces no browser, GraphQL, focus, or navigation failures.
diff --git a/viewers/webcomponents/foundation/src/menubar-element.mjs b/viewers/webcomponents/foundation/src/menubar-element.mjs
index bca679c82fe..e6554755844 100644
--- a/viewers/webcomponents/foundation/src/menubar-element.mjs
+++ b/viewers/webcomponents/foundation/src/menubar-element.mjs
@@ -190,6 +190,9 @@ export class CausewayMenubarElement extends HTMLElementBase {
     const disclosure = panel?.id
       ? this.querySelector?.(`[data-causeway-menu-disclosure][aria-controls="${globalThis.CSS?.escape ? CSS.escape(panel.id) : panel.id}"]`)
       : null;
+    if (disclosure) {
+      this.#closeMenu(disclosure, {focus: true});
+    }
     actionButton.dispatchEvent(createSemanticEvent(CausewaySemanticEvent.ACTION_REQUEST, Object.freeze({
       actionId,
       serviceLogicalTypeName,
@@ -197,9 +200,6 @@ export class CausewayMenubarElement extends HTMLElementBase {
       identity: null,
       context
     }), {cancelable: true}));
-    if (disclosure) {
-      this.#closeMenu(disclosure, {focus: true});
-    }
   }
 
   #handleKeydown(event) {
@@ -209,12 +209,14 @@ export class CausewayMenubarElement extends HTMLElementBase {
       const expanded = this.querySelector?.('[data-causeway-menu-disclosure][aria-expanded="true"]');
       if (expanded) {
         event.preventDefault();
+        event.stopPropagation();
         this.#closeMenu(expanded, {focus: true});
         return;
       }
       const barButton = this.querySelector?.('[data-causeway-bar-disclosure][aria-expanded="true"]');
       if (barButton) {
         event.preventDefault();
+        event.stopPropagation();
         this.#toggleDisclosure(barButton, false);
         barButton.focus?.();
       }
diff --git a/viewers/webcomponents/foundation/test/menubar-component.test.mjs b/viewers/webcomponents/foundation/test/menubar-component.test.mjs
index 74f2474fd29..fee33d28e36 100644
--- a/viewers/webcomponents/foundation/test/menubar-component.test.mjs
+++ b/viewers/webcomponents/foundation/test/menubar-component.test.mjs
@@ -74,6 +74,7 @@ test('menu action selection, outside activation, Escape, and sibling opening clo
   const secondPanel = {id: 'second-panel', hidden: true};
   const firstDisclosure = fakeDisclosure('first-panel', true);
   const secondDisclosure = fakeDisclosure('second-panel', false);
+  const barDisclosure = fakeDisclosure('bar-content', true);
   const action = {
     disabled: false,
     getAttribute(name) {
@@ -89,16 +90,19 @@ test('menu action selection, outside activation, Escape, and sibling opening clo
       return bar.dispatchEvent(event);
     }
   };
-  const known = new Set([firstDisclosure, secondDisclosure, firstPanel, secondPanel, action]);
+  const known = new Set([firstDisclosure, secondDisclosure, barDisclosure, firstPanel, secondPanel, action]);
   bar.contains = candidate => known.has(candidate);
   bar.querySelector = selector => {
     if (selector.startsWith('#first-panel')) return firstPanel;
     if (selector.startsWith('#second-panel')) return secondPanel;
     if (selector.includes('aria-controls="first-panel"')) return firstDisclosure;
     if (selector.includes('aria-controls="second-panel"')) return secondDisclosure;
-    if (selector.includes('aria-expanded="true"')) {
+    if (selector.includes('[data-causeway-menu-disclosure]') && selector.includes('aria-expanded="true"')) {
       return [firstDisclosure, secondDisclosure].find(disclosure => disclosure.getAttribute('aria-expanded') === 'true') ?? null;
     }
+    if (selector.includes('[data-causeway-bar-disclosure]') && selector.includes('aria-expanded="true"')) {
+      return barDisclosure.getAttribute('aria-expanded') === 'true' ? barDisclosure : null;
+    }
     return null;
   };
   bar.querySelectorAll = selector => selector.includes('aria-expanded="true"')
@@ -106,11 +110,16 @@ test('menu action selection, outside activation, Escape, and sibling opening clo
     : [];
 
   let request;
-  bar.addEventListener(CausewaySemanticEvent.ACTION_REQUEST, event => request = event.detail);
+  let requestCount = 0;
+  bar.addEventListener(CausewaySemanticEvent.ACTION_REQUEST, event => {
+    request = event.detail;
+    requestCount += 1;
+  });
   const actionClick = new Event('click');
   actionClick.target = action;
   bar.dispatchEvent(actionClick);
   assert.equal(request.actionId, 'welcomeMessage');
+  assert.equal(requestCount, 1);
   assert.equal(Object.hasOwn(request, 'focusTarget'), false);
   assert.equal(firstDisclosure.getAttribute('aria-expanded'), 'false');
   assert.equal(firstPanel.hidden, true);
@@ -137,8 +146,12 @@ test('menu action selection, outside activation, Escape, and sibling opening clo
   escape.key = 'Escape';
   escape.target = {closest: () => null};
   bar.dispatchEvent(escape);
+  assert.equal(escape.defaultPrevented, true);
   assert.equal(secondDisclosure.getAttribute('aria-expanded'), 'false');
+  assert.equal(secondPanel.hidden, true);
   assert.equal(secondDisclosure.focusCount, 1);
+  assert.equal(barDisclosure.getAttribute('aria-expanded'), 'true');
+  assert.equal(requestCount, 1);
   document.body.removeChild(bar);
 });
 
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java b/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
index 71dfd9bca17..5e13f7f93ae 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
@@ -206,6 +206,11 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
     void serviceActionsCoverValidationCancellationScalarAndCollections() {
         openHome();
 
+        openMenu("Pet Owners");
+        menuDisclosure("Pet Owners").press("Escape");
+        assertMenuClosedAndFocused("Pet Owners");
+        assertThat(page.locator(PROMPT).count()).isZero();
+
         openMenu("Pet Owners");
         page.locator(ROUTE_PAGE).dispatchEvent("click");
         assertMenuClosed("Pet Owners");
@@ -311,6 +316,8 @@ void disposableOwnerExercisesCreateAndEveryObjectAction() {
         openMenu("Pet Owners");
         serviceAction("create").click();
         waitForPrompt("create");
+        assertMenuClosed("Pet Owners");
+        assertFocused(parameter("name"));
         fillParameter("name", "Playwright Owner");
         fillParameter("knownAs", "PW");
         fillParameter("telephoneNumber", "020 7000 0000");