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

vatsrahul1001 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new b8987f0356c UI: Open nav sidebar menus on hover and add a Dags menu 
(#74745)
b8987f0356c is described below

commit b8987f0356ca6e95fc6f1277621b929b7cffa28f
Author: Brent Bovenzi <[email protected]>
AuthorDate: Sun Oct 18 02:52:11 2026 -0400

    UI: Open nav sidebar menus on hover and add a Dags menu (#74745)
    
    The standalone Dag Runs and Task Instances pages were only reachable by
    knowing their routes existed; nothing in the sidebar pointed at them.
    Hanging them off the Dags button keeps them discoverable without
    spending another slot in the sidebar, and opening on hover means the
    button itself can stay a plain link to the Dags list.
    
    The menus coordinate as a single menubar rather than independently: a
    menu left to close on its own delay pulls focus back to its own trigger,
    which dismisses the menu the pointer has just moved on to. For the same
    reason the group remembers where focus was before a hover and restores
    it afterwards, so sweeping the pointer across the sidebar cannot
    interrupt typing elsewhere on the page.
    
    Co-authored-by: Claude <[email protected]>
---
 .../src/airflow/ui/src/layouts/Nav/AdminButton.tsx |  20 +--
 .../airflow/ui/src/layouts/Nav/BrowseButton.tsx    |  20 +--
 .../airflow/ui/src/layouts/Nav/DagsButton.test.tsx |  67 ++++++++
 .../src/airflow/ui/src/layouts/Nav/DagsButton.tsx  |  77 +++++++++
 .../src/airflow/ui/src/layouts/Nav/DocsButton.tsx  |  56 +++---
 .../src/airflow/ui/src/layouts/Nav/Nav.tsx         |  11 +-
 .../airflow/ui/src/layouts/Nav/NavMenu.test.tsx    |  90 ++++++++++
 .../src/airflow/ui/src/layouts/Nav/NavMenu.tsx     | 170 ++++++++++++++++++
 .../src/airflow/ui/src/layouts/Nav/PluginMenus.tsx |  44 +++--
 .../airflow/ui/src/layouts/Nav/SecurityButton.tsx  |  26 +--
 .../ui/src/layouts/Nav/UserSettingsButton.tsx      | 190 ++++++++++-----------
 11 files changed, 578 insertions(+), 193 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx
index 946b96fd502..3e214b84f2f 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx
@@ -27,6 +27,7 @@ import { Menu } from "src/system-components";
 import type { NavItemResponse } from "src/utils/types";
 
 import { NavButton } from "./NavButton";
+import { NavMenu } from "./NavMenu";
 import { PluginMenuItem } from "./PluginMenuItem";
 
 const links = [
@@ -78,20 +79,19 @@ export const AdminButton = ({
   }
 
   return (
-    <Menu.Root positioning={{ placement: "right" }}>
-      <Menu.Trigger asChild>
+    <NavMenu
+      trigger={
         <NavButton
           icon={FiSettings}
           title={translate("nav.admin")}
           to={authorizedLinks.map(({ href }) => href)}
         />
-      </Menu.Trigger>
-      <Menu.Content>
-        {menuItems}
-        {externalViews.map((view) => (
-          <PluginMenuItem {...view} key={view.name} />
-        ))}
-      </Menu.Content>
-    </Menu.Root>
+      }
+    >
+      {menuItems}
+      {externalViews.map((view) => (
+        <PluginMenuItem {...view} key={view.name} />
+      ))}
+    </NavMenu>
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
index 1ff1e62c914..7667d1b636a 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
@@ -27,6 +27,7 @@ import { Menu } from "src/system-components";
 import type { NavItemResponse } from "src/utils/types";
 
 import { NavButton } from "./NavButton";
+import { NavMenu } from "./NavMenu";
 import { PluginMenuItem } from "./PluginMenuItem";
 
 const links = [
@@ -85,20 +86,19 @@ export const BrowseButton = ({
   }
 
   return (
-    <Menu.Root positioning={{ placement: "right" }}>
-      <Menu.Trigger asChild>
+    <NavMenu
+      trigger={
         <NavButton
           icon={FiGlobe}
           title={translate("nav.browse")}
           to={authorizedLinks.map(({ href }) => href)}
         />
-      </Menu.Trigger>
-      <Menu.Content>
-        {menuItems}
-        {externalViews.map((view) => (
-          <PluginMenuItem {...view} key={view.name} />
-        ))}
-      </Menu.Content>
-    </Menu.Root>
+      }
+    >
+      {menuItems}
+      {externalViews.map((view) => (
+        <PluginMenuItem {...view} key={view.name} />
+      ))}
+    </NavMenu>
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/DagsButton.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/DagsButton.test.tsx
new file mode 100644
index 00000000000..381f12de0f3
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/DagsButton.test.tsx
@@ -0,0 +1,67 @@
+/*!
+ * 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.
+ */
+import "@testing-library/jest-dom/vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { DagsButton } from "./DagsButton";
+
+const getTrigger = () => screen.getByRole("link", { name: "nav.dags" });
+
+describe("DagsButton", () => {
+  it("links straight to the Dags list", () => {
+    render(<DagsButton authorizedMenuItems={["Dags"]} />, { wrapper: Wrapper 
});
+
+    expect(getTrigger()).toHaveAttribute("href", "/dags");
+  });
+
+  it("reveals the dags, dag runs and task instances links on hover", async () 
=> {
+    render(<DagsButton authorizedMenuItems={["Dags"]} />, { wrapper: Wrapper 
});
+
+    expect(screen.queryByRole("menuitem")).toBeNull();
+
+    fireEvent.pointerEnter(getTrigger(), { pointerType: "mouse" });
+
+    await waitFor(() => {
+      expect(screen.getAllByRole("menuitem")).toHaveLength(3);
+    });
+
+    expect(screen.getAllByRole("menuitem").map((item) => 
item.getAttribute("href"))).toStrictEqual([
+      "/dags",
+      "/dag_runs",
+      "/task_instances",
+    ]);
+  });
+
+  it("ignores hover from a touch pointer", async () => {
+    render(<DagsButton authorizedMenuItems={["Dags"]} />, { wrapper: Wrapper 
});
+
+    fireEvent.pointerEnter(getTrigger(), { pointerType: "touch" });
+
+    await expect(waitFor(() => 
expect(screen.getAllByRole("menuitem")).toHaveLength(3))).rejects.toThrow();
+  });
+
+  it("renders a plain button with no menu when Dags is not authorized", () => {
+    render(<DagsButton authorizedMenuItems={[]} />, { wrapper: Wrapper });
+
+    expect(getTrigger()).not.toHaveAttribute("aria-haspopup");
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/DagsButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/DagsButton.tsx
new file mode 100644
index 00000000000..4618285a92c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/DagsButton.tsx
@@ -0,0 +1,77 @@
+/*!
+ * 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.
+ */
+import { useTranslation } from "react-i18next";
+import { Link as RouterLink } from "react-router-dom";
+
+import type { MenuItem } from "openapi/requests/types.gen";
+
+import { Menu } from "src/system-components";
+
+import { DagIcon } from "src/assets/DagIcon";
+
+import { NavButton } from "./NavButton";
+import { NavMenu } from "./NavMenu";
+
+const links = [
+  {
+    href: "/dags",
+    key: "dag_other",
+  },
+  {
+    href: "/dag_runs",
+    key: "dagRun_other",
+  },
+  {
+    href: "/task_instances",
+    key: "taskInstance_other",
+  },
+];
+
+export const DagsButton = ({ authorizedMenuItems }: { readonly 
authorizedMenuItems: Array<MenuItem> }) => {
+  const { t: translate } = useTranslation();
+  const isAuthorized = authorizedMenuItems.includes("Dags");
+
+  // Stays a link to the Dags list: hovering reveals the menu, clicking goes 
straight there.
+  const trigger = (
+    <NavButton
+      data-testid="nav-dags-link"
+      disabled={!isAuthorized}
+      icon={DagIcon}
+      matchPaths={["dag_runs", "task_instances"]}
+      title={translate("nav.dags")}
+      to="/dags"
+    />
+  );
+
+  if (!isAuthorized) {
+    return trigger;
+  }
+
+  return (
+    <NavMenu trigger={trigger}>
+      {links.map(({ href, key }) => (
+        <Menu.Item asChild key={key} value={key}>
+          <RouterLink aria-label={translate(key)} to={href}>
+            {translate(key)}
+          </RouterLink>
+        </Menu.Item>
+      ))}
+    </NavMenu>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx
index ea4f17865e0..eeed08580d3 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx
@@ -26,6 +26,7 @@ import { useConfig } from "src/queries/useConfig";
 import type { NavItemResponse } from "src/utils/types";
 
 import { NavButton } from "./NavButton";
+import { NavMenu } from "./NavMenu";
 import { PluginMenuItem } from "./PluginMenuItem";
 
 const baseUrl = document.querySelector("base")?.href ?? 
"http://localhost:8080/";;
@@ -60,39 +61,34 @@ export const DocsButton = ({
   const versionLink = 
`https://airflow.apache.org/docs/apache-airflow/${version}/index.html`;
 
   return (
-    <Menu.Root positioning={{ placement: "right" }}>
-      <Menu.Trigger asChild>
-        <NavButton icon={FiBookOpen} title={translate("nav.docs")} />
-      </Menu.Trigger>
-      <Menu.Content>
-        {links
-          .filter((link) => !(!showAPIDocs && link.key === "restApiReference"))
-          .map((link) => (
-            <Menu.Item asChild key={link.key} 
value={translate(`docs.${link.key}`)}>
-              <Link
-                aria-label={translate(`docs.${link.key}`)}
-                href={link.href}
-                rel="noopener noreferrer"
-                target="_blank"
-                textDecoration="none"
-              >
-                <Box flex="1">{translate(`docs.${link.key}`)}</Box>
-                <Icon as={FiExternalLink} boxSize={4} color="fg.muted" />
-              </Link>
-            </Menu.Item>
-          ))}
-        {version === undefined ? undefined : (
-          <Menu.Item asChild key={version} value={version}>
-            <Link aria-label={version} href={versionLink} rel="noopener 
noreferrer" target="_blank">
-              <Box flex="1">{version}</Box>
+    <NavMenu trigger={<NavButton icon={FiBookOpen} 
title={translate("nav.docs")} />}>
+      {links
+        .filter((link) => !(!showAPIDocs && link.key === "restApiReference"))
+        .map((link) => (
+          <Menu.Item asChild key={link.key} 
value={translate(`docs.${link.key}`)}>
+            <Link
+              aria-label={translate(`docs.${link.key}`)}
+              href={link.href}
+              rel="noopener noreferrer"
+              target="_blank"
+              textDecoration="none"
+            >
+              <Box flex="1">{translate(`docs.${link.key}`)}</Box>
               <Icon as={FiExternalLink} boxSize={4} color="fg.muted" />
             </Link>
           </Menu.Item>
-        )}
-        {externalViews.map((view) => (
-          <PluginMenuItem {...view} key={view.name} />
         ))}
-      </Menu.Content>
-    </Menu.Root>
+      {version === undefined ? undefined : (
+        <Menu.Item asChild key={version} value={version}>
+          <Link aria-label={version} href={versionLink} rel="noopener 
noreferrer" target="_blank">
+            <Box flex="1">{version}</Box>
+            <Icon as={FiExternalLink} boxSize={4} color="fg.muted" />
+          </Link>
+        </Menu.Item>
+      )}
+      {externalViews.map((view) => (
+        <PluginMenuItem {...view} key={view.name} />
+      ))}
+    </NavMenu>
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
index 45303a7083f..da4e42a4c8e 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
@@ -32,7 +32,6 @@ import { Tooltip } from "src/system-components";
 
 import { Logo } from "src/components/Logo";
 
-import { DagIcon } from "src/assets/DagIcon";
 import { useTimezone } from "src/context/timezone";
 import { useConfig } from "src/queries/useConfig";
 import { getTimezoneOffsetString, getTimezoneTooltipLabel } from 
"src/utils/datetimeUtils";
@@ -40,6 +39,7 @@ import type { NavItemResponse } from "src/utils/types";
 
 import { AdminButton } from "./AdminButton";
 import { BrowseButton } from "./BrowseButton";
+import { DagsButton } from "./DagsButton";
 import { DocsButton } from "./DocsButton";
 import { NavButton } from "./NavButton";
 import { PluginMenus } from "./PluginMenus";
@@ -180,14 +180,7 @@ export const Nav = () => {
           title={translate("nav.dashboard")}
           to="/home"
         />
-        <NavButton
-          data-testid="nav-dags-link"
-          disabled={!authLinks?.authorized_menu_items.includes("Dags")}
-          icon={DagIcon}
-          matchPaths={["dag_runs", "task_instances"]}
-          title={translate("nav.dags")}
-          to="/dags"
-        />
+        <DagsButton authorizedMenuItems={authLinks?.authorized_menu_items ?? 
[]} />
         <NavButton
           data-testid="nav-assets-link"
           disabled={!authLinks?.authorized_menu_items.includes("Assets")}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/NavMenu.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/NavMenu.test.tsx
new file mode 100644
index 00000000000..e4f179d7e6b
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/NavMenu.test.tsx
@@ -0,0 +1,90 @@
+/*!
+ * 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.
+ */
+import "@testing-library/jest-dom/vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { FiHome } from "react-icons/fi";
+import { describe, expect, it } from "vitest";
+
+import { Menu } from "src/system-components";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { NavButton } from "./NavButton";
+import { NavMenu } from "./NavMenu";
+
+const renderMenus = () =>
+  render(
+    <>
+      <NavMenu trigger={<NavButton icon={FiHome} title="First" />}>
+        <Menu.Item value="first-item">First item</Menu.Item>
+      </NavMenu>
+      <NavMenu trigger={<NavButton icon={FiHome} title="Second" />}>
+        <Menu.Item value="second-item">Second item</Menu.Item>
+      </NavMenu>
+    </>,
+    { wrapper: Wrapper },
+  );
+
+const getTrigger = (name: string) => screen.getByRole("button", { name });
+
+const hover = (name: string) => fireEvent.pointerEnter(getTrigger(name), { 
pointerType: "mouse" });
+
+// Comfortably past the delay on which a menu the pointer has left closes 
itself.
+const waitOutCloseDelay = async () =>
+  new Promise((resolve) => {
+    setTimeout(resolve, 500);
+  });
+
+describe("NavMenu", () => {
+  it("opens once the pointer has rested on the trigger", async () => {
+    renderMenus();
+
+    hover("First");
+
+    expect(getTrigger("First")).toHaveAttribute("aria-expanded", "false");
+
+    await waitFor(() => {
+      expect(getTrigger("First")).toHaveAttribute("aria-expanded", "true");
+    });
+  });
+
+  it("hands off to the next menu without leaving the previous one open", async 
() => {
+    renderMenus();
+
+    hover("First");
+    await waitFor(() => {
+      expect(getTrigger("First")).toHaveAttribute("aria-expanded", "true");
+    });
+
+    fireEvent.pointerLeave(getTrigger("First"), { pointerType: "mouse" });
+    hover("Second");
+
+    await waitFor(() => {
+      expect(getTrigger("Second")).toHaveAttribute("aria-expanded", "true");
+    });
+    expect(getTrigger("First")).toHaveAttribute("aria-expanded", "false");
+
+    // The menu being left has to close as part of the handoff. Closing later 
on its own
+    // delay would pull focus back to its trigger and dismiss the menu just 
opened.
+    await waitOutCloseDelay();
+
+    expect(getTrigger("Second")).toHaveAttribute("aria-expanded", "true");
+    expect(getTrigger("First")).toHaveAttribute("aria-expanded", "false");
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/NavMenu.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/NavMenu.tsx
new file mode 100644
index 00000000000..f54cce1828c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/NavMenu.tsx
@@ -0,0 +1,170 @@
+/*!
+ * 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.
+ */
+import { useCallback, useEffect, useId, useRef, useState, type PointerEvent, 
type ReactNode } from "react";
+
+import { Menu } from "src/system-components";
+
+// Opening the menu moves focus into its content, so require a short dwell: a 
pointer
+// sweeping across the sidebar on its way elsewhere must not pop menus open.
+const OPEN_DELAY_MS = 150;
+// The content is portalled and offset from the button, so leave a grace 
period for the
+// pointer to cross the gap between the two.
+const CLOSE_DELAY_MS = 250;
+
+const MENU_SELECTOR = '[data-scope="menu"]';
+
+// The sidebar menus behave as one menubar even though each owns its open 
state. Opening one
+// has to close the others synchronously: a menu left to close on its own 
delay afterwards
+// pulls focus back to its own trigger, which dismisses the menu the pointer 
moved on to.
+// A module-level group is enough, since the Nav is mounted once.
+const group: {
+  closeOthers: Map<string, () => void>;
+  // Where focus was before the pointer opened the first menu of a pass, so a 
hover the user
+  // never meant as an interaction does not steal focus from what they were 
typing in. It
+  // belongs to the group, not a menu: a pass may hand off several times 
before focus returns.
+  focusBeforeHover: Element | undefined;
+  openId: string | undefined;
+} = {
+  closeOthers: new Map(),
+  focusBeforeHover: undefined,
+  openId: undefined,
+};
+
+type NavMenuProps = {
+  readonly children: ReactNode;
+  readonly trigger: ReactNode;
+};
+
+/**
+ * A sidebar menu that opens when the pointer rests on its NavButton. Click, 
keyboard and
+ * touch still work through the underlying menu, so a trigger that is itself a 
link keeps
+ * navigating on click.
+ */
+export const NavMenu = ({ children, trigger }: NavMenuProps) => {
+  const id = useId();
+  const [open, setOpen] = useState(false);
+  const timeoutRef = useRef<ReturnType<typeof setTimeout> | 
undefined>(undefined);
+
+  const cancelPendingChange = useCallback(() => {
+    if (timeoutRef.current !== undefined) {
+      clearTimeout(timeoutRef.current);
+      timeoutRef.current = undefined;
+    }
+  }, []);
+
+  const closeMenu = useCallback(() => {
+    cancelPendingChange();
+    if (group.openId === id) {
+      group.openId = undefined;
+    }
+    setOpen(false);
+  }, [cancelPendingChange, id]);
+
+  const openMenu = useCallback(() => {
+    if (group.openId === undefined) {
+      group.focusBeforeHover = document.activeElement ?? undefined;
+    }
+    group.openId = id;
+    group.closeOthers.forEach((closeOther, otherId) => {
+      if (otherId !== id) {
+        closeOther();
+      }
+    });
+    setOpen(true);
+  }, [id]);
+
+  useEffect(() => {
+    group.closeOthers.set(id, closeMenu);
+
+    return () => {
+      group.closeOthers.delete(id);
+      if (group.openId === id) {
+        group.openId = undefined;
+      }
+      cancelPendingChange();
+    };
+  }, [cancelPendingChange, closeMenu, id]);
+
+  // The menu focuses its trigger as it closes; undo that only when the menu 
itself still
+  // holds focus, so focus moved by the user — or by the next menu — is left 
alone.
+  const restoreFocusBeforeHover = useCallback(() => {
+    const target = group.focusBeforeHover;
+
+    group.focusBeforeHover = undefined;
+
+    if (!(target instanceof HTMLElement) || !target.isConnected) {
+      return;
+    }
+
+    requestAnimationFrame(() => {
+      if (group.openId !== undefined || 
document.activeElement?.closest(MENU_SELECTOR) === null) {
+        return;
+      }
+      target.focus({ preventScroll: true });
+    });
+  }, []);
+
+  const hoverProps = {
+    onPointerEnter: (event: PointerEvent) => {
+      if (event.pointerType !== "mouse") {
+        return;
+      }
+      cancelPendingChange();
+      // Hand off straight away when the pointer is already travelling between 
open menus;
+      // only the first menu of a pass has to earn its dwell.
+      if (group.openId !== undefined && group.openId !== id) {
+        openMenu();
+
+        return;
+      }
+      timeoutRef.current = setTimeout(openMenu, OPEN_DELAY_MS);
+    },
+    onPointerLeave: (event: PointerEvent) => {
+      if (event.pointerType !== "mouse") {
+        return;
+      }
+      cancelPendingChange();
+      timeoutRef.current = setTimeout(() => {
+        closeMenu();
+        restoreFocusBeforeHover();
+      }, CLOSE_DELAY_MS);
+    },
+  };
+
+  return (
+    <Menu.Root
+      onOpenChange={({ open: isOpen }) => {
+        if (isOpen) {
+          openMenu();
+        } else {
+          closeMenu();
+        }
+        // Clicks, Escape and item selection own focus from here on.
+        group.focusBeforeHover = undefined;
+      }}
+      open={open}
+      positioning={{ placement: "right" }}
+    >
+      <Menu.Trigger asChild {...hoverProps}>
+        {trigger}
+      </Menu.Trigger>
+      <Menu.Content {...hoverProps}>{children}</Menu.Content>
+    </Menu.Root>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx
index 9c7416aaf1f..1e15e8963ea 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, Icon } from "@chakra-ui/react";
+import { Icon } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
 import { FiChevronRight } from "react-icons/fi";
 import { LuPlug } from "react-icons/lu";
@@ -26,6 +26,7 @@ import { Menu } from "src/system-components";
 import type { NavItemResponse } from "src/utils/types";
 
 import { NavButton } from "./NavButton";
+import { NavMenu } from "./NavMenu";
 import { PluginMenuItem } from "./PluginMenuItem";
 
 export const PluginMenus = ({ navItems }: { readonly navItems: 
Array<NavItemResponse> }) => {
@@ -65,29 +66,24 @@ export const PluginMenus = ({ navItems }: { readonly 
navItems: Array<NavItemResp
         <PluginMenuItem key={navItem.name} {...navItem} topLevel={true} />
       ))}
       {showRemainingInMenu ? (
-        <Menu.Root positioning={{ placement: "right" }}>
-          <Menu.Trigger>
-            <NavButton as={Box} icon={LuPlug} title={translate("nav.plugins")} 
to={remainingPaths} />
-          </Menu.Trigger>
-          <Menu.Content>
-            {remainingButtons.map((navItem) => (
-              <PluginMenuItem key={navItem.name} {...navItem} />
-            ))}
-            {Object.entries(remainingCategories).map(([key, menuButtons]) => (
-              <Menu.Root key={key} positioning={{ placement: "right" }}>
-                <Menu.TriggerItem display="flex" 
justifyContent="space-between">
-                  {key}
-                  <Icon as={FiChevronRight} boxSize={4} color="fg.muted" />
-                </Menu.TriggerItem>
-                <Menu.Content>
-                  {menuButtons.map((navItem) => (
-                    <PluginMenuItem {...navItem} key={navItem.name} />
-                  ))}
-                </Menu.Content>
-              </Menu.Root>
-            ))}
-          </Menu.Content>
-        </Menu.Root>
+        <NavMenu trigger={<NavButton icon={LuPlug} 
title={translate("nav.plugins")} to={remainingPaths} />}>
+          {remainingButtons.map((navItem) => (
+            <PluginMenuItem key={navItem.name} {...navItem} />
+          ))}
+          {Object.entries(remainingCategories).map(([key, menuButtons]) => (
+            <Menu.Root key={key} positioning={{ placement: "right" }}>
+              <Menu.TriggerItem display="flex" justifyContent="space-between">
+                {key}
+                <Icon as={FiChevronRight} boxSize={4} color="fg.muted" />
+              </Menu.TriggerItem>
+              <Menu.Content>
+                {menuButtons.map((navItem) => (
+                  <PluginMenuItem {...navItem} key={navItem.name} />
+                ))}
+              </Menu.Content>
+            </Menu.Root>
+          ))}
+        </NavMenu>
       ) : (
         remainingItems.map((navItem) => <PluginMenuItem key={navItem.name} 
{...navItem} topLevel={true} />)
       )}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx
index 72d76d049a9..d5de0efcc02 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx
@@ -25,6 +25,7 @@ import { useAuthLinksServiceGetAuthMenus } from 
"openapi/queries";
 import { Menu } from "src/system-components";
 
 import { NavButton } from "./NavButton";
+import { NavMenu } from "./NavMenu";
 
 export const SecurityButton = () => {
   const { data: authLinks } = useAuthLinksServiceGetAuthMenus();
@@ -41,23 +42,22 @@ export const SecurityButton = () => {
   });
 
   return (
-    <Menu.Root positioning={{ placement: "right" }}>
-      <Menu.Trigger asChild>
+    <NavMenu
+      trigger={
         <NavButton
           icon={FiLock}
           title={translate("nav.security")}
           to={securityItems.map(({ path }) => path)}
         />
-      </Menu.Trigger>
-      <Menu.Content>
-        {securityItems.map(({ path, securityKey, text }) => (
-          <Menu.Item asChild key={text} value={text}>
-            <Link aria-label={text} to={path}>
-              {translate(`security.${securityKey}`)}
-            </Link>
-          </Menu.Item>
-        ))}
-      </Menu.Content>
-    </Menu.Root>
+      }
+    >
+      {securityItems.map(({ path, securityKey, text }) => (
+        <Menu.Item asChild key={text} value={text}>
+          <Link aria-label={text} to={path}>
+            {translate(`security.${securityKey}`)}
+          </Link>
+        </Menu.Item>
+      ))}
+    </NavMenu>
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
index 16be3b4bd5f..070b04d86e6 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
@@ -42,6 +42,7 @@ import type { NavItemResponse } from "src/utils/types";
 import LanguageModal from "./LanguageModal";
 import LogoutModal from "./LogoutModal";
 import { NavButton } from "./NavButton";
+import { NavMenu } from "./NavMenu";
 import { PluginMenuItem } from "./PluginMenuItem";
 import TokenGenerationModal from "./TokenGenerationModal";
 
@@ -87,104 +88,99 @@ export const UserSettingsButton = ({ externalViews }: { 
readonly externalViews:
 
   return (
     <>
-      <Menu.Root positioning={{ placement: "right" }}>
-        <Menu.Trigger asChild>
-          <NavButton icon={FiUser} title={translate("user")} />
-        </Menu.Trigger>
-        <Menu.Content>
-          {currentUser ? (
-            <>
-              <Box p={3}>
-                <Box color="fg.muted" fontSize="sm">
-                  {translate("signedInAs")}
-                </Box>
-                <Box fontSize="md" fontWeight="semibold">
-                  {`${currentUser.username} (id: ${currentUser.id})`}
-                </Box>
-                {Array.isArray(currentUser.teams) ? (
-                  <>
-                    <Box color="fg.muted" fontSize="sm" mt={2}>
-                      {translate("teams.title")}
-                    </Box>
-                    {currentUser.teams.length ? (
-                      <HStack fontSize="sm" gap={2} wrap="wrap">
-                        {currentUser.teams.slice(0, 
MAX_VISIBLE_TEAMS).map((team) => (
-                          <Box key={team}>{team}</Box>
-                        ))}
-                        {currentUser.teams.length > MAX_VISIBLE_TEAMS ? (
-                          <Tooltip
-                            closeDelay={0}
-                            content={
-                              <VStack align="start" gap={0}>
-                                
{currentUser.teams.slice(MAX_VISIBLE_TEAMS).map((team) => (
-                                  <Box key={team}>{team}</Box>
-                                ))}
-                              </VStack>
-                            }
-                            openDelay={0}
-                            portalled
-                          >
-                            <Box color="fg.muted" textDecoration="underline 
dotted">
-                              {translate("teams.more", {
-                                count: currentUser.teams.length - 
MAX_VISIBLE_TEAMS,
-                              })}
-                            </Box>
-                          </Tooltip>
-                        ) : undefined}
-                      </HStack>
-                    ) : (
-                      <Box color="fg.muted" fontSize="sm">
-                        {translate("teams.none")}
-                      </Box>
-                    )}
-                  </>
-                ) : undefined}
+      <NavMenu trigger={<NavButton icon={FiUser} title={translate("user")} />}>
+        {currentUser ? (
+          <>
+            <Box p={3}>
+              <Box color="fg.muted" fontSize="sm">
+                {translate("signedInAs")}
+              </Box>
+              <Box fontSize="md" fontWeight="semibold">
+                {`${currentUser.username} (id: ${currentUser.id})`}
               </Box>
-              <Menu.Separator />
-            </>
-          ) : undefined}
-          <Menu.Item asChild value="settings">
-            <RouterLink color="inherit" to="/settings">
-              <Icon as={FiSettings} boxSize={4} />
-              <Box flex="1">{translate("settings.title")}</Box>
-            </RouterLink>
-          </Menu.Item>
-          <Menu.Item onClick={onOpenLanguage} value="language">
-            <Icon as={FiGlobe} boxSize={4} />
-            <Box flex="1">{translate("selectLanguage")}</Box>
-          </Menu.Item>
-          <Menu.Root>
-            <Menu.TriggerItem>
-              <Icon as={FiEye} boxSize={4} />
-              <Box flex="1">{translate("appearance.appearance")}</Box>
-              <Icon as={isRTL ? FiChevronLeft : FiChevronRight} boxSize={4} 
color="fg.muted" />
-            </Menu.TriggerItem>
-            <Menu.Content>
-              <Menu.RadioItemGroup onValueChange={(element) => 
setColorMode(element.value)} value={theme}>
-                {colorModeOptions.map(({ icon, label, value }) => (
-                  <Menu.RadioItem key={value} value={value}>
-                    <Icon as={icon} boxSize={4} />
-                    <Box flex="1">{label}</Box>
-                    <Menu.ItemIndicator color="fg.muted" />
-                  </Menu.RadioItem>
-                ))}
-              </Menu.RadioItemGroup>
-            </Menu.Content>
-          </Menu.Root>
-          <Menu.Item onClick={onOpenToken} value="generateToken">
-            <Icon as={FiKey} boxSize={4} />
-            <Box flex="1">{translate("generateToken")}</Box>
-          </Menu.Item>
-          {externalViews.map((view) => (
-            <PluginMenuItem {...view} key={view.name} />
-          ))}
-          <Menu.Separator />
-          <Menu.Item onClick={onOpenLogout} value="logout">
-            <Icon as={FiLogOut} boxSize={4} transform={isRTL ? 
"rotate(180deg)" : undefined} />
-            <Box flex="1">{translate("logout")}</Box>
-          </Menu.Item>
-        </Menu.Content>
-      </Menu.Root>
+              {Array.isArray(currentUser.teams) ? (
+                <>
+                  <Box color="fg.muted" fontSize="sm" mt={2}>
+                    {translate("teams.title")}
+                  </Box>
+                  {currentUser.teams.length ? (
+                    <HStack fontSize="sm" gap={2} wrap="wrap">
+                      {currentUser.teams.slice(0, 
MAX_VISIBLE_TEAMS).map((team) => (
+                        <Box key={team}>{team}</Box>
+                      ))}
+                      {currentUser.teams.length > MAX_VISIBLE_TEAMS ? (
+                        <Tooltip
+                          closeDelay={0}
+                          content={
+                            <VStack align="start" gap={0}>
+                              
{currentUser.teams.slice(MAX_VISIBLE_TEAMS).map((team) => (
+                                <Box key={team}>{team}</Box>
+                              ))}
+                            </VStack>
+                          }
+                          openDelay={0}
+                          portalled
+                        >
+                          <Box color="fg.muted" textDecoration="underline 
dotted">
+                            {translate("teams.more", {
+                              count: currentUser.teams.length - 
MAX_VISIBLE_TEAMS,
+                            })}
+                          </Box>
+                        </Tooltip>
+                      ) : undefined}
+                    </HStack>
+                  ) : (
+                    <Box color="fg.muted" fontSize="sm">
+                      {translate("teams.none")}
+                    </Box>
+                  )}
+                </>
+              ) : undefined}
+            </Box>
+            <Menu.Separator />
+          </>
+        ) : undefined}
+        <Menu.Item asChild value="settings">
+          <RouterLink color="inherit" to="/settings">
+            <Icon as={FiSettings} boxSize={4} />
+            <Box flex="1">{translate("settings.title")}</Box>
+          </RouterLink>
+        </Menu.Item>
+        <Menu.Item onClick={onOpenLanguage} value="language">
+          <Icon as={FiGlobe} boxSize={4} />
+          <Box flex="1">{translate("selectLanguage")}</Box>
+        </Menu.Item>
+        <Menu.Root>
+          <Menu.TriggerItem>
+            <Icon as={FiEye} boxSize={4} />
+            <Box flex="1">{translate("appearance.appearance")}</Box>
+            <Icon as={isRTL ? FiChevronLeft : FiChevronRight} boxSize={4} 
color="fg.muted" />
+          </Menu.TriggerItem>
+          <Menu.Content>
+            <Menu.RadioItemGroup onValueChange={(element) => 
setColorMode(element.value)} value={theme}>
+              {colorModeOptions.map(({ icon, label, value }) => (
+                <Menu.RadioItem key={value} value={value}>
+                  <Icon as={icon} boxSize={4} />
+                  <Box flex="1">{label}</Box>
+                  <Menu.ItemIndicator color="fg.muted" />
+                </Menu.RadioItem>
+              ))}
+            </Menu.RadioItemGroup>
+          </Menu.Content>
+        </Menu.Root>
+        <Menu.Item onClick={onOpenToken} value="generateToken">
+          <Icon as={FiKey} boxSize={4} />
+          <Box flex="1">{translate("generateToken")}</Box>
+        </Menu.Item>
+        {externalViews.map((view) => (
+          <PluginMenuItem {...view} key={view.name} />
+        ))}
+        <Menu.Separator />
+        <Menu.Item onClick={onOpenLogout} value="logout">
+          <Icon as={FiLogOut} boxSize={4} transform={isRTL ? "rotate(180deg)" 
: undefined} />
+          <Box flex="1">{translate("logout")}</Box>
+        </Menu.Item>
+      </NavMenu>
       <LanguageModal isOpen={isOpenLanguage} onClose={onCloseLanguage} />
       <LogoutModal isOpen={isOpenLogout} onClose={onCloseLogout} />
       <TokenGenerationModal isOpen={isOpenToken} onClose={onCloseToken} />

Reply via email to