You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@myfaces.apache.org by hn...@apache.org on 2026/04/23 07:25:41 UTC

(myfaces-tobago) 01/02: fix(popup): dropdown menus

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

henningn pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/myfaces-tobago.git

commit 47b55a02a81573b84a189b7aed5c37e9a449d6d5
Author: Henning Noeth <hn...@apache.org>
AuthorDate: Mon Apr 13 10:24:59 2026 +0200

    fix(popup): dropdown menus
    
    * add test
    * focus the popup after opening, so TAB selects the first tabable element and TAB+SHIFT selects the last tabable element in the popup.
    
    Issue: TOBAGO-2336, TOBAGO-2507
---
 .../content/900-test/popup/dropdown/Dropdown.xhtml |  76 +++++++++++
 .../src/test/typescript/popup.test.ts              | 139 +++++++++++++++++++++
 .../src/main/ts/tobago-popup.ts                    |  43 ++++++-
 3 files changed, 252 insertions(+), 6 deletions(-)

diff --git a/tobago-example/tobago-example-demo/src/main/webapp/content/900-test/popup/dropdown/Dropdown.xhtml b/tobago-example/tobago-example-demo/src/main/webapp/content/900-test/popup/dropdown/Dropdown.xhtml
new file mode 100644
index 0000000000..c8559c3418
--- /dev/null
+++ b/tobago-example/tobago-example-demo/src/main/webapp/content/900-test/popup/dropdown/Dropdown.xhtml
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+-->
+
+<ui:composition template="/main.xhtml"
+                xmlns="http://www.w3.org/1999/xhtml"
+                xmlns:f="jakarta.faces.core"
+                xmlns:tc="http://myfaces.apache.org/tobago/component"
+                xmlns:ui="jakarta.faces.facelets">
+
+  <p>Components with dropdown menu inside a popup.</p>
+
+  <tc:button id="popup-open" label="Open popup">
+    <tc:operation name="show" for="popup"/>
+    <f:ajax execute="popup" render="popup"/>
+  </tc:button>
+
+  <tc:popup id="popup">
+    <f:facet name="label">Popup</f:facet>
+    <f:facet name="bar">
+      <tc:link id="popup-x" icon="bi-x-lg" immediate="true">
+        <tc:operation name="hide" for="popup"/>
+        <f:ajax execute="popup" render="popup"/>
+      </tc:link>
+    </f:facet>
+
+    <tc:in id="inSuggest" label="tc:suggest" labelLayout="top">
+      <tc:suggest id="suggest" totalCount="10" query="#{suggestController.query}">
+        <tc:selectItems value="#{suggestController.solarObjects}" var="name" itemValue="#{name}"/>
+      </tc:suggest>
+    </tc:in>
+
+    <tc:selectOneList id="selectOneList" label="tc:selectOneList" labelLayout="top">
+      <tc:selectItems value="#{selectOneListController.planets}" var="planet"
+                      itemLabel="#{planet.name}" itemValue="#{planet}"/>
+    </tc:selectOneList>
+
+    <tc:selectManyList id="selectManyList" label="tc:selectManyList" labelLayout="top">
+      <tc:selectItems value="#{selectManyListController.planets}" var="planet"
+                      itemLabel="#{planet.name}" itemValue="#{planet}"/>
+    </tc:selectManyList>
+
+    <tc:button id="musicPlayerButton" label="MusicPlayer" omit="true">
+      <tc:link id="play" icon="bi-play-circle" label="Play"/>
+      <tc:link icon="bi-pause-circle" label="Pause"/>
+      <tc:link icon="bi-stop-circle" label="Stop"/>
+      <tc:link id="track" label="Track">
+        <tc:link label="1"/>
+        <tc:link label="2"/>
+        <tc:link label="3"/>
+      </tc:link>
+    </tc:button>
+
+    <f:facet name="footer">
+      <tc:button id="popup-close" label="Close" immediate="true">
+        <tc:operation name="hide" for="popup"/>
+        <f:ajax execute="popup" render="popup"/>
+      </tc:button>
+    </f:facet>
+  </tc:popup>
+</ui:composition>
diff --git a/tobago-example/tobago-example-demo/src/test/typescript/popup.test.ts b/tobago-example/tobago-example-demo/src/test/typescript/popup.test.ts
index 4d273ac397..d0276dd34e 100644
--- a/tobago-example/tobago-example-demo/src/test/typescript/popup.test.ts
+++ b/tobago-example/tobago-example-demo/src/test/typescript/popup.test.ts
@@ -203,3 +203,142 @@ test.describe("130-collapsible/10-collapsible-popup/Collapsible_Popup.xhtml", ()
     await expect(messages).toHaveCount(1);
   });
 });
+
+test.describe("900-test/popup/dropdown/Dropdown.xhtml", () => {
+
+  test.beforeEach(async ({page}, testInfo) => {
+    await page.goto("/content/900-test/popup/dropdown/Dropdown.xhtml");
+  });
+
+  test("Tabbing the popup", async ({page, browserName}) => {
+    const popupOpen = page.locator("[id='page:mainForm:popup-open']");
+    const popup = page.locator("[id='page:mainForm:popup']");
+    const popupModalDialog = popup.locator(".modal-dialog");
+    const popupX = page.locator("[id='page:mainForm:popup:popup-x']");
+    const suggestInputField = page.locator("[id='page:mainForm:popup:inSuggest::field']");
+    const suggestDropdownMenu = page.locator(".tobago-dropdown-menu[data-tobago-for='page:mainForm:popup:suggest']");
+    const mars = suggestDropdownMenu.locator("li[data-result-index='0']");
+    const amalthea = suggestDropdownMenu.locator("li[data-result-index='1']");
+    const himalia = suggestDropdownMenu.locator("li[data-result-index='2']");
+    const mimas = suggestDropdownMenu.locator("li[data-result-index='3']");
+    const selectOneList = page.locator("[id='page:mainForm:popup:selectOneList']");
+    const selectOneListMenu = page.locator(".tobago-dropdown-menu[data-tobago-for='page:mainForm:popup:selectOneList']");
+    const selectOneListMercuryRow = selectOneListMenu.locator("tr[data-tobago-value='Mercury']");
+    const selectOneListPlutoRow = selectOneListMenu.locator("tr[data-tobago-value='Pluto']");
+    const selectManyList = page.locator("[id='page:mainForm:popup:selectManyList']");
+    const selectManyListMenu = page.locator(".tobago-dropdown-menu[data-tobago-for='page:mainForm:popup:selectManyList']");
+    const selectManyListMercuryRow = selectManyListMenu.locator("tr[data-tobago-value='Mercury']");
+    const selectManyListPlutoRow = selectManyListMenu.locator("tr[data-tobago-value='Pluto']");
+    const musicPlayerButton = page.locator("[id='page:mainForm:popup:musicPlayerButton::command']");
+    const play = page.locator("[id='page:mainForm:popup:play']");
+    const track = page.locator("[id='page:mainForm:popup:track::command']");
+    const popupClose = page.locator("[id='page:mainForm:popup:popup-close']");
+
+    await expect(popup).not.toContainClass("show");
+    await popupOpen.click();
+    await expect(popupModalDialog).not.toHaveCSS("transform", "none"); //animation
+    await expect(popupModalDialog).toHaveCSS("transform", "none"); //animation done
+    await expect(popup).toContainClass("show");
+    await page.keyboard.press("Tab");
+    await expect(popupX).toBeFocused();
+
+    //suggest
+    await page.keyboard.press("Tab");
+    await expect(suggestInputField).toBeFocused();
+    await page.keyboard.press("m");
+    await page.keyboard.press("a");
+    await expect(mars).toBeVisible();
+    await expect(amalthea).toBeVisible();
+    await expect(himalia).toBeVisible();
+    await expect(mimas).toBeVisible();
+    await page.keyboard.press("Tab");
+    await expect(mars.locator("button")).toBeFocused();
+    await page.keyboard.down("Shift");
+    await page.keyboard.press("Tab");
+    await page.keyboard.up("Shift");
+    await expect(mimas.locator("button")).toBeFocused();
+    await page.keyboard.press("Escape");
+    await expect(mars).not.toBeVisible();
+    await expect(amalthea).not.toBeVisible();
+    await expect(himalia).not.toBeVisible();
+    await expect(mimas).not.toBeVisible();
+    await expect(popup).toContainClass("show");
+
+    //selectOneList
+    await expect(selectOneList).not.toContainClass("tobago-focus");
+    await page.keyboard.press("Tab");
+    await expect(selectOneList).toContainClass("tobago-focus");
+    await page.keyboard.press("Enter");
+    await expect(selectOneListMercuryRow).toBeVisible();
+    await expect(selectOneListMercuryRow).not.toContainClass("tobago-preselect");
+    await page.keyboard.press("Tab");
+    await expect(selectOneListMercuryRow).toContainClass("tobago-preselect");
+    await page.keyboard.down("Shift");
+    await page.keyboard.press("Tab");
+    await page.keyboard.up("Shift");
+    await expect(selectOneListMercuryRow).not.toContainClass("tobago-preselect");
+    await expect(selectOneListPlutoRow).toBeVisible();
+    await expect(selectOneListPlutoRow).toContainClass("tobago-preselect");
+    await page.keyboard.press("Escape");
+    await expect(selectOneListMercuryRow).not.toBeVisible();
+    await expect(selectOneListPlutoRow).not.toBeVisible();
+
+    //selectManyList
+    await expect(selectManyList).not.toContainClass("tobago-focus");
+    await page.keyboard.press("Tab");
+    await expect(selectManyList).toContainClass("tobago-focus");
+    await page.keyboard.press("Enter");
+    await expect(selectManyListMercuryRow).toBeVisible();
+    await expect(selectManyListMercuryRow).not.toContainClass("tobago-preselect");
+    await page.keyboard.press("Tab");
+    await expect(selectManyListMercuryRow).toContainClass("tobago-preselect");
+    await page.keyboard.down("Shift");
+    await page.keyboard.press("Tab");
+    await page.keyboard.up("Shift");
+    await expect(selectManyListMercuryRow).not.toContainClass("tobago-preselect");
+    await expect(selectManyListPlutoRow).toBeVisible();
+    await expect(selectManyListPlutoRow).toContainClass("tobago-preselect");
+    await page.keyboard.press("Escape");
+    await expect(selectManyListMercuryRow).not.toBeVisible();
+    await expect(selectManyListPlutoRow).not.toBeVisible();
+
+    //MusicPlayer dropdown menu
+    await expect(musicPlayerButton).not.toBeFocused();
+    await page.keyboard.press("Tab");
+    await expect(musicPlayerButton).toBeFocused();
+    await page.keyboard.press("Enter");
+    await expect(play).toBeVisible();
+    await expect(play).not.toBeFocused();
+    await page.keyboard.press("Tab");
+    await expect(play).toBeFocused();
+    await page.keyboard.down("Shift");
+    await page.keyboard.press("Tab");
+    await page.keyboard.up("Shift");
+    await expect(track).toBeVisible();
+    await expect(track).toBeFocused();
+    await page.keyboard.press("Escape");
+    await expect(play).not.toBeVisible();
+    await expect(track).not.toBeVisible();
+
+    await page.keyboard.press("Tab");
+    await expect(popupClose).toBeFocused();
+    await page.keyboard.press("Tab");
+    await expect(popupX).toBeFocused();
+    await page.keyboard.down("Shift");
+    await page.keyboard.press("Tab");
+    await page.keyboard.up("Shift");
+    await expect(popupClose).toBeFocused();
+
+    await expect(popup).toContainClass("show");
+    await page.keyboard.press("Escape");
+    await expect(popup).not.toContainClass("show");
+    await popupOpen.click();
+    await expect(popupModalDialog).not.toHaveCSS("transform", "none"); //animation
+    await expect(popupModalDialog).toHaveCSS("transform", "none"); //animation done
+    await expect(popup).toContainClass("show");
+    await page.keyboard.down("Shift");
+    await page.keyboard.press("Tab");
+    await page.keyboard.up("Shift");
+    await expect(popupClose).toBeFocused();
+  });
+});
diff --git a/tobago-theme/tobago-theme-standard/src/main/ts/tobago-popup.ts b/tobago-theme/tobago-theme-standard/src/main/ts/tobago-popup.ts
index c63efa6ed6..f32d1525e6 100644
--- a/tobago-theme/tobago-theme-standard/src/main/ts/tobago-popup.ts
+++ b/tobago-theme/tobago-theme-standard/src/main/ts/tobago-popup.ts
@@ -19,6 +19,8 @@ import {Modal} from "bootstrap";
 import {BehaviorMode} from "./tobago-behavior-mode";
 import {Collapse} from "./tobago-collapse";
 import {EventListenerStore} from "./util/EventListenerStore";
+import {FocusableElement, tabbable} from "tabbable";
+import {Key} from "./tobago-key";
 
 const BootstrapPopupEvent = {
   HIDE: "hide.bs.modal",
@@ -37,12 +39,9 @@ export class Popup extends HTMLElement {
   }
 
   connectedCallback(): void {
-    const options = {};
-    this.modal = new Modal(this, options);
-    if (!this.collapsed) {
-      this.clientBehaviorShow();
-    }
-
+    this.modal = new Modal(this, {focus: false});
+    this.listeners.add(this, "keydown", this.keydownEvent.bind(this));
+    this.listeners.add(this, BootstrapPopupEvent.SHOWN, () => this.focus());
     this.listeners.add(this, BootstrapPopupEvent.HIDDEN, () => {
       /**
        * Make sure that collapsed=true is set when the popup is closed by clicking on the background or pressing ESC.
@@ -51,6 +50,10 @@ export class Popup extends HTMLElement {
         this.collapsed = true;
       }
     });
+
+    if (!this.collapsed) {
+      this.clientBehaviorShow();
+    }
   }
 
   disconnectedCallback(): void {
@@ -78,6 +81,34 @@ export class Popup extends HTMLElement {
     }
   }
 
+  private keydownEvent(event: KeyboardEvent): void {
+    switch (event.key) {
+      case Key.ESCAPE:
+        event.stopPropagation();
+        this.modal.hide();
+        break;
+      case Key.TAB: {
+        const focusableElements: FocusableElement[] = tabbable(this);
+        const firstFocusableElement = focusableElements[0];
+        const lastFocusableElement = focusableElements[focusableElements.length - 1];
+        if (event.shiftKey) {
+          if (document.activeElement === this || document.activeElement === firstFocusableElement) {
+            event.preventDefault();
+            lastFocusableElement.focus();
+          }
+        } else {
+          if (document.activeElement === this || document.activeElement === lastFocusableElement) {
+            event.preventDefault();
+            firstFocusableElement.focus();
+          }
+        }
+      }
+        break;
+      default:
+        break;
+    }
+  }
+
   get collapsed(): boolean {
     return JSON.parse(Collapse.findHidden(this).value);
   }