From 85a970019a908d4ccd5d6f6c637c0e2fcb278ae5 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 21 Jun 2026 19:50:01 +0800 Subject: [PATCH] feat(DropdownMenu): add opt-in openOnHover (mouse-only, click-coexisting) Layer hover open/close on top of the existing click toggle via the same openMenu/closeMenu helpers. Pointer handlers attach to the outer container so trigger->menu travel stays inside it; a 150ms private close-grace timer bridges the gap and is cleared by every closer (select/Escape/Tab/outside-click) plus unmount. Mouse-only: touch/pen early-return and keep tap-to-toggle. Keyboard, focus, and roles unchanged; add aria-haspopup/expanded/controls to the trigger. openOnHover defaults to false, so existing call sites render byte-for-byte as before (both handlers undefined). Patch bump 0.5.12 -> 0.5.13 per pre-1.0 kit convention (needs the release label on the PR). New Storybook OpenOnHover story + 7 hover tests added. COUPLING NOTE: depends on the menu rendering inline (no portal); a future portal move would require mirroring the hover handlers onto the menu element. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- .../dropdown-menu/DropdownMenu.stories.tsx | 25 ++++ .../dropdown-menu/DropdownMenu.test.tsx | 118 +++++++++++++++++- .../navigation/dropdown-menu/DropdownMenu.tsx | 70 ++++++++++- 4 files changed, 208 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 2145e6bb2..47574ec74 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mirrorstack-ai/web-ui-kit", "packageManager": "pnpm@10.29.3", - "version": "0.5.12", + "version": "0.5.13", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/src/components/ui/navigation/dropdown-menu/DropdownMenu.stories.tsx b/src/components/ui/navigation/dropdown-menu/DropdownMenu.stories.tsx index 7d9bc24c7..55dfc2206 100644 --- a/src/components/ui/navigation/dropdown-menu/DropdownMenu.stories.tsx +++ b/src/components/ui/navigation/dropdown-menu/DropdownMenu.stories.tsx @@ -64,6 +64,31 @@ export const CustomIconAndExternal: Story = { }, }; +/** + * `openOnHover` opens the menu on mouse hover of the trigger, layered on top of + * click. The menu stays open while the pointer is over the trigger or the menu, + * and closes after a short grace on leave so trigger→menu travel does not + * dismiss it. Mouse-only — touch/pen keep tap-to-toggle. Reach for it on a + * desktop toolbar overflow menu, never a mobile bottom-nav menu. Hover over the + * button below to open it. + */ +export const OpenOnHover: Story = { + args: { + openOnHover: true, + trigger: ( + + ), + items: [ + { id: "rename", label: "Rename", icon: "edit" }, + { id: "move", label: "Move to…", icon: "drive_file_move" }, + { type: "separator" as const }, + { id: "archive", label: "Archive", icon: "archive" }, + { id: "delete", label: "Delete", icon: "delete", variant: "error" as const }, + ], + onSelect: (item) => console.log("Selected:", item.id), + }, +}; + /** * The notch head tuned to a small trigger: `notchWidth`/`notchHeight` size the * tab to the icon button, and `notchRadius`/`notchInverseRadius` round the head diff --git a/src/components/ui/navigation/dropdown-menu/DropdownMenu.test.tsx b/src/components/ui/navigation/dropdown-menu/DropdownMenu.test.tsx index 92fd89f80..ee4848e35 100644 --- a/src/components/ui/navigation/dropdown-menu/DropdownMenu.test.tsx +++ b/src/components/ui/navigation/dropdown-menu/DropdownMenu.test.tsx @@ -1,10 +1,34 @@ -import { cleanup, render, screen, fireEvent } from "@testing-library/react"; -import { describe, expect, it, afterEach, vi } from "vitest"; +import { cleanup, render, screen, fireEvent, act } from "@testing-library/react"; +import { + describe, + expect, + it, + afterEach, + beforeEach, + beforeAll, + afterAll, + vi, +} from "vitest"; import { DropdownMenu } from "./DropdownMenu"; import type { DropdownMenuEntry } from "./DropdownMenu"; afterEach(cleanup); +const HOVER_CLOSE_GRACE_MS = 150; + +// JSDOM ships no PointerEvent, and fireEvent.pointerEnter(el, { pointerType }) +// silently drops the init dict — leaving `e.pointerType` undefined, which the +// component's mouse-only guard treats as not-a-mouse. Stub a MouseEvent +// subclass that carries pointerType so hover events are dispatched as the +// component sees them in a real browser. +class StubPointerEvent extends MouseEvent { + pointerType: string; + constructor(type: string, init: PointerEventInit = {}) { + super(type, init); + this.pointerType = init.pointerType ?? ""; + } +} + const items: DropdownMenuEntry[] = [ { id: "edit", label: "Edit", icon: "edit" }, { id: "duplicate", label: "Duplicate", icon: "content_copy" }, @@ -62,3 +86,93 @@ describe("DropdownMenu", () => { ); }); }); + +describe("DropdownMenu openOnHover", () => { + const realPointerEvent = globalThis.PointerEvent; + beforeAll(() => { + globalThis.PointerEvent = StubPointerEvent as unknown as typeof PointerEvent; + }); + afterAll(() => { + globalThis.PointerEvent = realPointerEvent; + }); + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + const mouse = { pointerType: "mouse" }; + + // The hover handlers live on the OUTER container div (`relative inline-block`), + // and pointerenter/pointerleave do not bubble — so fire them directly on the + // container, not on a descendant (the trigger button or a menu item). + const renderHover = (onSelect: (item: { id: string }) => void = () => {}) => { + const { container } = render( + , + ); + return container.firstChild as HTMLElement; + }; + + it("opens on pointerEnter (mouse)", () => { + const root = renderHover(); + fireEvent.pointerEnter(root, mouse); + expect(screen.getByRole("menu")).toBeInTheDocument(); + }); + + it("stays open when the pointer moves trigger->menu within the grace", () => { + const root = renderHover(); + fireEvent.pointerEnter(root, mouse); + // Leave the container (queues a close)... + fireEvent.pointerLeave(root, mouse); + // ...but re-enter before the grace elapses cancels the close. (The menu is + // a container descendant, so trigger->menu travel never leaves the container + // in real use; here we re-enter the container itself to model that.) + act(() => vi.advanceTimersByTime(HOVER_CLOSE_GRACE_MS - 50)); + fireEvent.pointerEnter(root, mouse); + act(() => vi.advanceTimersByTime(HOVER_CLOSE_GRACE_MS)); + expect(screen.getByRole("menu")).toBeInTheDocument(); + }); + + it("closes after the grace on pointerLeave", () => { + const root = renderHover(); + fireEvent.pointerEnter(root, mouse); + expect(screen.getByRole("menu")).toBeInTheDocument(); + fireEvent.pointerLeave(root, mouse); + act(() => vi.advanceTimersByTime(HOVER_CLOSE_GRACE_MS)); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("does NOT hover-open for touch pointers", () => { + const root = renderHover(); + fireEvent.pointerEnter(root, { pointerType: "touch" }); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("still closes on Escape with openOnHover on", () => { + const root = renderHover(); + fireEvent.pointerEnter(root, mouse); + fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" }); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("still selects (and closes) on item click with openOnHover on", () => { + const onSelect = vi.fn(); + const root = renderHover(onSelect); + fireEvent.pointerEnter(root, mouse); + fireEvent.click(screen.getByText("Edit")); + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ id: "edit" }), + ); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("click still toggles a hover-opened menu closed", () => { + const root = renderHover(); + fireEvent.pointerEnter(root, mouse); + expect(screen.getByRole("menu")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Open")); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/navigation/dropdown-menu/DropdownMenu.tsx b/src/components/ui/navigation/dropdown-menu/DropdownMenu.tsx index 07444d072..edae6be9e 100644 --- a/src/components/ui/navigation/dropdown-menu/DropdownMenu.tsx +++ b/src/components/ui/navigation/dropdown-menu/DropdownMenu.tsx @@ -20,6 +20,12 @@ const DD_R = 16; const DD_IR = 10; const DD_SW = 1.5; +// Grace before a hover-opened menu closes on pointer-leave. Outlasts a normal +// mouse traversal of the trigger->menu gap (8px no-notch / ~0 notch) so the +// menu doesn't dismiss mid-travel; short enough to feel responsive on an +// intentional leave. Private by design — no caller needs to tune it yet. +const HOVER_CLOSE_GRACE_MS = 150; + // Item density tokens per `size` — "lg" pads up to comfortable touch targets. const SIZES = { md: { item: "gap-2 px-2 py-1.5", icon: 16, minW: "min-w-[180px]", sep: "mx-1.5" }, @@ -85,6 +91,18 @@ export interface DropdownMenuProps { * touch targets — use it for menus opened on touch surfaces (e.g. a mobile * bottom nav). `"md"` is the default desktop density. */ size?: "md" | "lg"; + /** Open the menu on mouse hover of the trigger, in addition to click. + * The menu stays open while the pointer is over the trigger OR the floating + * menu, and closes after a short grace on leave so trigger->menu travel does + * not dismiss it. Mouse-only: no-op on touch/pen (which keep tap-to-toggle) + * and for keyboard. Default `false` — hover menus hurt touch and keyboard + * discoverability, so opt in deliberately (e.g. a desktop toolbar overflow + * menu, not a mobile bottom-nav menu). + * + * COUPLING NOTE: relies on the menu rendering INLINE inside `containerRef` + * (no portal). If DropdownMenu ever moves to a portal, hover-leave detection + * must be reworked (the menu would no longer be a container descendant). */ + openOnHover?: boolean; /** Class applied to the floating menu element (the notched card) — use it to * fine-tune the menu's position relative to the trigger, e.g. a translate * to nudge the whole notched card off the auto-aligned anchor. */ @@ -113,6 +131,7 @@ export function DropdownMenu({ useNotch = true, placement = "bottom", size = "md", + openOnHover = false, menuClassName, className, }: DropdownMenuProps) { @@ -123,10 +142,18 @@ export function DropdownMenu({ const containerRef = useRef(null); const menuRef = useRef(null); const contentRef = useRef(null); + const closeTimer = useRef(null); const [contentH, setContentH] = useState(0); const [menuW, setMenuW] = useState(0); const menuId = useId(); + const clearCloseTimer = useCallback(() => { + if (closeTimer.current != null) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + }, []); + const actionableIndices = items .map((entry, i) => (isActionable(entry) ? i : -1)) .filter((i) => i !== -1); @@ -151,14 +178,16 @@ export function DropdownMenu({ }); const openMenu = useCallback(() => { + clearCloseTimer(); setOpen(true); setActiveIndex(actionableIndices[0] ?? -1); - }, [actionableIndices, setActiveIndex]); + }, [actionableIndices, setActiveIndex, clearCloseTimer]); const closeMenu = useCallback(() => { + clearCloseTimer(); setOpen(false); setActiveIndex(-1); - }, [setActiveIndex]); + }, [setActiveIndex, clearCloseTimer]); useEffect(() => { if (!open) return; @@ -176,8 +205,14 @@ export function DropdownMenu({ return () => { cancelAnimationFrame(raf); document.removeEventListener("mousedown", handleClickOutside); + // A pending hover-close must not setState after the menu closes. + clearCloseTimer(); }; - }, [open, closeMenu]); + }, [open, closeMenu, clearCloseTimer]); + + // Guaranteed unmount kill: the open-effect only runs while open, so it alone + // does not cover unmount-while-closed. + useEffect(() => clearCloseTimer, [clearCloseTimer]); useLayoutEffect(() => { if (!open || !contentRef.current) return; @@ -186,9 +221,36 @@ export function DropdownMenu({ }, [open, items.length]); return ( -
+
{ + if (e.pointerType !== "mouse") return; // touch/pen -> tap-to-toggle only + clearCloseTimer(); + if (!open) openMenu(); + } + : undefined + } + onPointerLeave={ + openOnHover + ? (e) => { + if (e.pointerType !== "mouse") return; + clearCloseTimer(); + closeTimer.current = window.setTimeout(() => { + closeTimer.current = null; + closeMenu(); + }, HOVER_CLOSE_GRACE_MS); + } + : undefined + } + >
{ if (open) { closeMenu();