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);
}