Repository: primefaces/primefaces (java)

Issue:
Tooltip: autoHide="false" never gets hidden
### Describe the bug

Hello

I detected a new bug on 14.0.1 related to issue #12098 and its corresponding fix #12112. The tooltip gets stuck if the component's or any of its parents' ID have a colon or any other characters that should be escaped in a selector.

I'll be submitting a PR soon for this.

### Reproducer

1. Have a tooltip with autoHide="false" set
2. Show the tooltip.
3. It is never hidden because an exception is caught

### Expected behavior

Tooltip should hide when mouse is moved out of its trigger component and the tooltip itself

### PrimeFaces edition

Community

### PrimeFaces version

14.0.1

### Theme

Saga

### JSF implementation

Mojarra

### JSF version

4.0

### Java version

21

### Browser(s)

_No response_

Pull request description:
Fix #12118: 14.0.2 Tooltip: escape selector for mouseTarget.closest
Fix #12118: 14.0.2 Tooltip: escape selector for mouseTarget.closest

Patch:
diff --git a/pom.xml b/pom.xml
index 5841627510..8a7f1e2175 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
     <groupId>org.primefaces</groupId>
     <artifactId>primefaces-parent</artifactId>
     <packaging>pom</packaging>
-    <version>14.0.1</version>
+    <version>14.0.2-SNAPSHOT</version>
 
     <name>PrimeFaces Parent</name>
     <url>https://www.primefaces.org</url>
diff --git a/primefaces-cli/pom.xml b/primefaces-cli/pom.xml
index fb6aa6141f..64c44c5e77 100644
--- a/primefaces-cli/pom.xml
+++ b/primefaces-cli/pom.xml
@@ -7,7 +7,7 @@
     <parent>
         <groupId>org.primefaces</groupId>
         <artifactId>primefaces-parent</artifactId>
-        <version>14.0.1</version>
+        <version>14.0.2-SNAPSHOT</version>
         <relativePath>../pom.xml</relativePath>
     </parent>
 
diff --git a/primefaces-selenium/pom.xml b/primefaces-selenium/pom.xml
index 6992a90014..0f782274be 100644
--- a/primefaces-selenium/pom.xml
+++ b/primefaces-selenium/pom.xml
@@ -7,7 +7,7 @@
     <parent>
         <groupId>org.primefaces</groupId>
         <artifactId>primefaces-parent</artifactId>
-        <version>14.0.1</version>
+        <version>14.0.2-SNAPSHOT</version>
         <relativePath>../pom.xml</relativePath>
     </parent>
 
diff --git a/primefaces-selenium/primefaces-selenium-components/pom.xml b/primefaces-selenium/primefaces-selenium-components/pom.xml
index ada9dbe0b3..955d9d0c69 100644
--- a/primefaces-selenium/primefaces-selenium-components/pom.xml
+++ b/primefaces-selenium/primefaces-selenium-components/pom.xml
@@ -7,7 +7,7 @@
     <parent>
         <groupId>org.primefaces</groupId>
         <artifactId>primefaces-selenium</artifactId>
-        <version>14.0.1</version>
+        <version>14.0.2-SNAPSHOT</version>
         <relativePath>../pom.xml</relativePath>
     </parent>
 
diff --git a/primefaces-selenium/primefaces-selenium-core/pom.xml b/primefaces-selenium/primefaces-selenium-core/pom.xml
index 351891680d..6a5c51ca1b 100644
--- a/primefaces-selenium/primefaces-selenium-core/pom.xml
+++ b/primefaces-selenium/primefaces-selenium-core/pom.xml
@@ -7,7 +7,7 @@
     <parent>
         <groupId>org.primefaces</groupId>
         <artifactId>primefaces-selenium</artifactId>
-        <version>14.0.1</version>
+        <version>14.0.2-SNAPSHOT</version>
         <relativePath>../pom.xml</relativePath>
     </parent>
 
diff --git a/primefaces-showcase/pom.xml b/primefaces-showcase/pom.xml
index e9f45a7092..2e846939b5 100644
--- a/primefaces-showcase/pom.xml
+++ b/primefaces-showcase/pom.xml
@@ -7,7 +7,7 @@
     <parent>
         <groupId>org.primefaces</groupId>
         <artifactId>primefaces-parent</artifactId>
-        <version>14.0.1</version>
+        <version>14.0.2-SNAPSHOT</version>
         <relativePath>../pom.xml</relativePath>
     </parent>
 
diff --git a/primefaces/pom.xml b/primefaces/pom.xml
index 4091f1bd81..563e50162a 100644
--- a/primefaces/pom.xml
+++ b/primefaces/pom.xml
@@ -7,7 +7,7 @@
     <parent>
         <groupId>org.primefaces</groupId>
         <artifactId>primefaces-parent</artifactId>
-        <version>14.0.1</version>
+        <version>14.0.2-SNAPSHOT</version>
         <relativePath>../pom.xml</relativePath>
     </parent>
 
diff --git a/primefaces/src/main/resources/META-INF/resources/primefaces/menu/menu.plainmenu.js b/primefaces/src/main/resources/META-INF/resources/primefaces/menu/menu.plainmenu.js
index a4e128cbd8..f902e46983 100644
--- a/primefaces/src/main/resources/META-INF/resources/primefaces/menu/menu.plainmenu.js
+++ b/primefaces/src/main/resources/META-INF/resources/primefaces/menu/menu.plainmenu.js
@@ -30,83 +30,61 @@ PrimeFaces.widget.PlainMenu = PrimeFaces.widget.Menu.extend({
 
         this.menuitemLinks = this.jq.find('.ui-menuitem-link:not(.ui-state-disabled)');
 
-        //events
         this.bindEvents();
-
-        if (this.cfg.toggleable) {
-            this.cfg.statefulGlobal = Boolean(this.cfg.statefulGlobal);
-            this.collapsedIds = [];
-            this.createStorageKey();
-            this.restoreState();
-        }
+        this.bindToggleable();
+        this.bindOverlay();
     },
 
     /**
-     * Sets up all event listeners required by this widget.
+     * Binds the necessary events for the menu if it is toggleable. This includes setting up the state management
+     * by initializing the storage key and restoring the state from storage.
      * @private
      */
-    bindEvents: function() {
-        var $this = this;
-
-        // make first focusable
-        this.menuitemLinks.filter(':not([disabled])').first().attr("tabindex", "0");
-        this.resetFocus(true);
-
-        this.menuitemLinks.on("mouseenter.menu click.menu", function(e) {
-            $(this).trigger('focus');
-        })
-            .on("focusin.menu", function(e) {
-                $this.focus($(this));
-            })
-            .on("focusout.menu mouseleave.menu", function(e) {
-                $this.unfocus($(this));
-            })
-            .on('keydown.menu', function(e) {
-                var currentLink = $this.menuitemLinks.filter('.ui-state-hover');
-                switch (e.code) {
-                    case 'ArrowUp':
-                        var prevItem = currentLink.parent().prevAll('.ui-menuitem:first');
-                        if (prevItem.length) {
-                            $this.unfocus(currentLink);
-                            $this.focus(prevItem.children('.ui-menuitem-link'));
-                        }
-
-                        e.preventDefault();
-                        break;
+    bindToggleable: function() {
+        if (!this.cfg.toggleable) return;
 
-                    case 'ArrowDown':
-                        var nextItem = currentLink.parent().nextAll('.ui-menuitem:first');
-                        if (nextItem.length) {
-                            $this.unfocus(currentLink);
-                            $this.focus(nextItem.children('.ui-menuitem-link'));
-                        }
-
-                        e.preventDefault();
-                        break;
-
-                    case 'Space':
-                    case 'Enter':
-                    case 'NumpadEnter':
-                        currentLink.trigger('click');
-                        PrimeFaces.utils.openLink(e, currentLink);
-                        break;
+        this.cfg.statefulGlobal = Boolean(this.cfg.statefulGlobal);
+        this.collapsedIds = [];
+        this.createStorageKey();
+        this.restoreState();
 
-                    case 'Escape':
-                        $this.hide();
+        var $this = this;
+        var headers = this.jq.find('> .ui-menu-list > .ui-widget-header');
+        
+        headers.on('mouseover.menu', function() {
+            $(this).addClass('ui-state-hover');
+        }).on('mouseout.menu', function() {
+            $(this).removeClass('ui-state-hover');
+        }).on('click.menu', function(e) {
+            var header = $(this);
+            var icon = header.find('> h3 > .ui-icon');
+
+            if (icon.hasClass('ui-icon-triangle-1-s')) {
+                $this.collapseSubmenu(header, true);
+            } else {
+                $this.expandSubmenu(header, true);
+            }
 
-                        if ($this.cfg.overlay) {
-                            $this.trigger.trigger('focus');
-                        }
-                        break;
+            PrimeFaces.clearSelection();
+            e.preventDefault();
+        });
+    },
 
-                }
-            });
+    /**
+     * Binds overlay-specific event handlers if the overlay configuration is enabled.
+     * This includes hiding the menu on certain key presses or clicks, and managing focus.
+     * @private
+     */
+    bindOverlay: function() {
+        var $this = this;
 
         if (this.cfg.overlay) {
+            // Hide menu when any menu item link is clicked
             this.menuitemLinks.on("click", function() {
                 $this.hide();
             });
 
+            // Handle keyboard navigation for overlay
             this.trigger.on('keydown.ui-menu', function(e) {
                 switch (e.key) {
                     case 'ArrowDown':
@@ -123,33 +101,75 @@ PrimeFaces.widget.PlainMenu = PrimeFaces.widget.Menu.extend({
                         break;
                 }
             });
-        }
-        else {
+        } else {
+            // Reset focus when focus moves out of the menu and the new focus target is not within the menu
             this.jq.on("focusout.menu", function(e) {
                 if (!$this.jq.has(e.relatedTarget).length) {
                     $this.resetFocus(true);
                 }
             });
         }
+    },
+ 
+    /**
+     * Binds event handlers to menu item links for interaction via mouse and keyboard.
+     * This includes setting the initial focus, handling mouse enter and leave, click events,
+     * and keyboard navigation using arrow keys, space, and enter.
+     * @private
+     */
+    bindEvents: function() {
+        var $this = this;
+
+        // Set the first focusable menu item
+        this.menuitemLinks.filter(':not([disabled])').first().attr("tabindex", "0");
+        this.resetFocus(true);
 
-        if (this.cfg.toggleable) {
-            this.jq.find('> .ui-menu-list > .ui-widget-header').on('mouseover.menu', function() {
-                $(this).addClass('ui-state-hover');
-            })
-                .on('mouseout.menu', function() {
-                    $(this).removeClass('ui-state-hover');
-                })
-                .on('click.menu', function(e) {
-                    var header = $(this);
-
-                    if (header.find('> h3 > .ui-icon').hasClass('ui-icon-triangle-1-s'))
-                        $this.collapseSubmenu(header, true);
-                    else
-                        $this.expandSubmenu(header, true);
-
-                    PrimeFaces.clearSelection();
+        // Bind mouse and click events to focus items
+        this.menuitemLinks.on("mouseenter.menu click.menu", function() {
+            $this.focus($(this));
+        }).on("focusout.menu mouseleave.menu", function() {
+            $this.unfocus($(this));
+        });
+
+        // Bind keyboard navigation events
+        this.menuitemLinks.on('keydown.menu', function(e) {
+            var currentLink = $this.menuitemLinks.filter('.ui-state-hover');
+            switch (e.code) {
+                case 'ArrowUp':
+                    $this.navigateMenu(currentLink, 'prev');
+                    e.preventDefault();
+                    break;
+                case 'ArrowDown':
+                    $this.navigateMenu(currentLink, 'next');
                     e.preventDefault();
-                });
+                    break;
+                case 'Space':
+                case 'Enter':
+                case 'NumpadEnter':
+                    currentLink.trigger('click');
+                    PrimeFaces.utils.openLink(e, currentLink);
+                    break;
+                case 'Escape':
+                    $this.hide();
+                    if ($this.cfg.overlay) {
+                        $this.trigger.trigger('focus');
+                    }
+                    break;
+            }
+        });
+    },
+
+    /**
+     * Navigates the menu items in the specified direction ('prev' or 'next').
+     * @param {JQuery} currentLink The currently focused menu item link.
+     * @param {string} direction The direction to navigate ('prev' or 'next').
+     * @private
+     */
+    navigateMenu: function(currentLink, direction) {
+        var targetItem = currentLink.parent()[direction + 'All']('.ui-menuitem:first');
+        if (targetItem.length) {
+            this.unfocus(currentLink);
+            this.focus(targetItem.children('.ui-menuitem-link'));
         }
     },
 
diff --git a/primefaces/src/main/resources/META-INF/resources/primefaces/tooltip/tooltip.js b/primefaces/src/main/resources/META-INF/resources/primefaces/tooltip/tooltip.js
index 53a7a1378d..2e44c8269a 100644
--- a/primefaces/src/main/resources/META-INF/resources/primefaces/tooltip/tooltip.js
+++ b/primefaces/src/main/resources/META-INF/resources/primefaces/tooltip/tooltip.js
@@ -302,7 +302,9 @@ PrimeFaces.widget.Tooltip = PrimeFaces.widget.BaseWidget.extend({
             var mouseTarget = $(e.relatedTarget);
             var previousElement = this.target;
             this.allowHide = !(mouseTarget.is(previousElement) ||
-                               previousElement.attr('aria-describedby') === mouseTarget.closest('#' + this.id).attr('id'));
+                               previousElement.attr('aria-describedby') === mouseTarget.closest(PrimeFaces.escapeClientId(this.id)).attr('id') ||
+                               mouseTarget.attr('aria-describedby') === this.id ||
+                               mouseTarget.parent().attr('aria-describedby') === this.id);
         }
         if (this.allowHide) {
             this.hide();


Test patch:
diff --git a/primefaces-integration-tests-jakarta/pom.xml b/primefaces-integration-tests-jakarta/pom.xml
index 0a924b9c4e..8163e41f32 100644
--- a/primefaces-integration-tests-jakarta/pom.xml
+++ b/primefaces-integration-tests-jakarta/pom.xml
@@ -7,7 +7,7 @@
     <parent>
         <groupId>org.primefaces</groupId>
         <artifactId>primefaces-parent</artifactId>
-        <version>14.0.1</version>
+        <version>14.0.2-SNAPSHOT</version>
         <relativePath>../pom.xml</relativePath>
     </parent>
 
diff --git a/primefaces-integration-tests/pom.xml b/primefaces-integration-tests/pom.xml
index 95a7cc519f..d5a12b4ab9 100644
--- a/primefaces-integration-tests/pom.xml
+++ b/primefaces-integration-tests/pom.xml
@@ -7,7 +7,7 @@
     <parent>
         <groupId>org.primefaces</groupId>
         <artifactId>primefaces-parent</artifactId>
-        <version>14.0.1</version>
+        <version>14.0.2-SNAPSHOT</version>
         <relativePath>../pom.xml</relativePath>
     </parent>