Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
<IconButton icon="more_horiz" aria-label="More" variant="text" />
),
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
Expand Down
118 changes: 116 additions & 2 deletions src/components/ui/navigation/dropdown-menu/DropdownMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -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" },
Expand Down Expand Up @@ -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(
<DropdownMenu
items={items}
onSelect={onSelect}
trigger={trigger}
openOnHover
/>,
);
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();
});
});
70 changes: 66 additions & 4 deletions src/components/ui/navigation/dropdown-menu/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -113,6 +131,7 @@ export function DropdownMenu({
useNotch = true,
placement = "bottom",
size = "md",
openOnHover = false,
menuClassName,
className,
}: DropdownMenuProps) {
Expand All @@ -123,10 +142,18 @@ export function DropdownMenu({
const containerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const closeTimer = useRef<number | null>(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);
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -186,9 +221,36 @@ export function DropdownMenu({
}, [open, items.length]);

return (
<div ref={containerRef} className={cn("relative inline-block", className)}>
<div
ref={containerRef}
className={cn("relative inline-block", className)}
onPointerEnter={
openOnHover
? (e) => {
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
}
>
<div
className={cn("relative", open && "z-[51]")}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={() => {
if (open) {
closeMenu();
Expand Down
Loading