Skip to content

Commit

Permalink
fix: Fix submenu closing when trying to move into it
Browse files Browse the repository at this point in the history
  • Loading branch information
diegohaz committed Apr 20, 2020
1 parent 8537b5b commit 38f0c0b
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 27 deletions.
52 changes: 27 additions & 25 deletions packages/reakit/src/Menu/MenuItem.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import * as React from "react";
import { createComponent } from "reakit-system/createComponent";
import { createHook } from "reakit-system/createHook";
import { getDocument } from "reakit-utils/getDocument";
import { contains } from "reakit-utils/contains";
import { useLiveRef } from "reakit-utils/useLiveRef";
import { hasFocusWithin } from "reakit-utils/hasFocusWithin";
import {
unstable_CompositeItemOptions as CompositeItemOptions,
unstable_CompositeItemHTMLProps as CompositeItemHTMLProps,
unstable_useCompositeItem as useCompositeItem,
} from "../Composite/CompositeItem";
import { useMenuState, MenuStateReturn } from "./MenuState";
import { MenuContext } from "./__utils/MenuContext";
import { findVisibleSubmenu } from "./__utils/findVisibleSubmenu";
import { useTransitToSubmenu } from "./__utils/useTransitToSubmenu";
import { isExpandedDisclosure } from "./__utils/isExpandedDisclosure";

export type MenuItemOptions = CompositeItemOptions &
Pick<Partial<MenuStateReturn>, "visible" | "hide" | "placement"> &
Expand All @@ -20,18 +23,12 @@ export type MenuItemHTMLProps = CompositeItemHTMLProps;

export type MenuItemProps = MenuItemOptions & MenuItemHTMLProps;

function isExpandedDisclosure(element: HTMLElement) {
return (
element.hasAttribute("aria-controls") &&
element.getAttribute("aria-expanded") === "true"
);
}

function getMouseDestination(event: React.MouseEvent<HTMLElement, MouseEvent>) {
const relatedTarget = event.relatedTarget as Node | null;
if (relatedTarget?.nodeType === Node.ELEMENT_NODE) {
return event.relatedTarget;
}
// IE 11
return (event as any).toElement || null;
}

Expand All @@ -42,17 +39,14 @@ function hoveringInside(event: React.MouseEvent<HTMLElement, MouseEvent>) {
}

function hoveringExpandedMenu(
event: React.MouseEvent<HTMLElement, MouseEvent>
event: React.MouseEvent<HTMLElement, MouseEvent>,
children?: Array<React.RefObject<HTMLElement>>
) {
const self = event.currentTarget;
if (!children?.length) return false;
const nextElement = getMouseDestination(event);
if (!nextElement) return false;
const document = getDocument(self);
if (!isExpandedDisclosure(self)) return false;
const menuId = self.getAttribute("aria-controls");
const menu = document.getElementById(menuId!);
if (!menu) return false;
return contains(menu, nextElement);
const visibleSubmenu = findVisibleSubmenu(children);
return visibleSubmenu && contains(visibleSubmenu, nextElement);
}

function hoveringAnotherMenuItem(
Expand All @@ -72,23 +66,29 @@ export const useMenuItem = createHook<MenuItemOptions, MenuItemHTMLProps>({
options,
{
onMouseEnter: htmlOnMouseEnter,
onMouseMove: htmlOnMouseMove,
onMouseLeave: htmlOnMouseLeave,
...htmlProps
}
) {
const menu = React.useContext(MenuContext);
const menuRole = menu?.role;
const onMouseEnterRef = useLiveRef(htmlOnMouseEnter);
const onMouseMoveRef = useLiveRef(htmlOnMouseMove);
const onMouseLeaveRef = useLiveRef(htmlOnMouseLeave);
const { onMouseEnter, isMouseInTransitToSubmenu } = useTransitToSubmenu(
menu,
htmlOnMouseEnter
);

const onMouseEnter = React.useCallback(
const onMouseMove = React.useCallback(
(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
onMouseEnterRef.current?.(event);
onMouseMoveRef.current?.(event);
if (event.defaultPrevented) return;
if (menuRole === "menubar") return;
if (menu?.role === "menubar") return;
if (isMouseInTransitToSubmenu(event)) return;
if (hasFocusWithin(event.currentTarget)) return;
event.currentTarget.focus();
},
[menuRole]
[]
);

const onMouseLeave = React.useCallback(
Expand All @@ -99,20 +99,22 @@ export const useMenuItem = createHook<MenuItemOptions, MenuItemHTMLProps>({
if (hoveringInside(event)) return;
// If this item is a menu disclosure and mouse is leaving it to focus
// its respective submenu, we don't want to do anything.
if (hoveringExpandedMenu(event)) return;
if (hoveringExpandedMenu(event, menu?.children)) return;
// On menu bars, hovering out of disclosure doesn't blur it.
if (menuRole === "menubar" && isExpandedDisclosure(self)) return;
if (menu?.role === "menubar" && isExpandedDisclosure(self)) return;
// Move focus to menu after blurring
if (!hoveringAnotherMenuItem(event, options.items)) {
if (isMouseInTransitToSubmenu(event)) return;
options.move?.(null);
}
},
[menuRole, options.items, options.move]
[menu?.role, menu?.children, options.items, options.move]
);

return {
role: "menuitem",
onMouseEnter,
onMouseMove,
onMouseLeave,
...htmlProps,
};
Expand Down
4 changes: 2 additions & 2 deletions packages/reakit/src/Menu/__tests__/index-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ import {
expect(submenu).toBeVisible();
await wait(expect(subitem1).toHaveFocus);
press.ArrowLeft();
expect(submenu).not.toBeVisible();
await wait(expect(submenu).not.toBeVisible);
expect(subdisclosure).toHaveFocus();
});

Expand Down Expand Up @@ -1005,7 +1005,7 @@ import {
press.ArrowDown();
expect(subitem3).toHaveFocus();
press.ArrowLeft();
expect(submenu).not.toBeVisible();
await wait(expect(submenu).not.toBeVisible);
expect(subdisclosure).toHaveFocus();
});

Expand Down
6 changes: 6 additions & 0 deletions packages/reakit/src/Menu/__utils/isExpandedDisclosure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function isExpandedDisclosure(element: HTMLElement) {
return (
element.hasAttribute("aria-controls") &&
element.getAttribute("aria-expanded") === "true"
);
}
97 changes: 97 additions & 0 deletions packages/reakit/src/Menu/__utils/useTransitToSubmenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import * as React from "react";
import { useLiveRef } from "reakit-utils/useLiveRef";
import { MenuContextType } from "./MenuContext";
import { findVisibleSubmenu } from "./findVisibleSubmenu";

type Point = { x: number; y: number };

function getTriangleArea(a: Point, b: Point, c: Point) {
return Math.abs(
(a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2
);
}

function isPointInTriangle(point: Point, a: Point, b: Point, c: Point) {
const A = getTriangleArea(a, b, c);
const A1 = getTriangleArea(point, b, c);
const A2 = getTriangleArea(a, point, c);
const A3 = getTriangleArea(a, b, point);
return A === A1 + A2 + A3;
}

function getSubmenuAnchorPoints(
event: React.MouseEvent,
visibleSubmenu: HTMLElement
) {
const { top, right, bottom, left } = visibleSubmenu.getBoundingClientRect();
// If left is bigger than mouse's clientX, than the submenu is visible on
// the left side
const x = left > event.clientX ? left : right;
return [
{ x, y: top },
{ x, y: bottom },
] as const;
}

export function useTransitToSubmenu(
menu: MenuContextType | null,
htmlOnMouseEnter?: React.MouseEventHandler
) {
const onMouseEnterRef = useLiveRef(htmlOnMouseEnter);
const enterPointRef = React.useRef<Point | null>(null);
const submenuTopPointRef = React.useRef<Point | null>(null);
const submenuBottomPointRef = React.useRef<Point | null>(null);
const previousClientX = React.useRef(0);

const assignSubmenuAnchorPoints = React.useCallback(
(event: React.MouseEvent) => {
if (!menu?.children.length) return;
submenuTopPointRef.current = null;
submenuBottomPointRef.current = null;
const visibleSubmenu = findVisibleSubmenu(menu.children);
if (!visibleSubmenu) return;
[
submenuTopPointRef.current,
submenuBottomPointRef.current,
] = getSubmenuAnchorPoints(event, visibleSubmenu);
},
[menu?.children]
);

const isMouseInTransitToSubmenu = React.useCallback(
(event: React.MouseEvent) => {
const movementX = Math.abs(previousClientX.current - event.clientX);
previousClientX.current = event.clientX;
const hasAnchorPoints = () =>
submenuTopPointRef.current && submenuBottomPointRef.current;
if (event.type === "mouseleave" && !hasAnchorPoints()) {
assignSubmenuAnchorPoints(event);
}
if (!hasAnchorPoints()) return false;
return (
movementX &&
enterPointRef.current &&
isPointInTriangle(
{ x: event.clientX, y: event.clientY },
enterPointRef.current,
submenuTopPointRef.current!,
submenuBottomPointRef.current!
)
);
},
[assignSubmenuAnchorPoints]
);

const onMouseEnter = React.useCallback(
(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
onMouseEnterRef.current?.(event);
if (event.defaultPrevented) return;
if (menu?.role === "menubar") return;
enterPointRef.current = { x: event.clientX, y: event.clientY };
assignSubmenuAnchorPoints(event);
},
[menu?.role, assignSubmenuAnchorPoints]
);

return { onMouseEnter, isMouseInTransitToSubmenu };
}

0 comments on commit 38f0c0b

Please sign in to comment.