|
|
|
@ -1,41 +1,22 @@
|
|
|
|
|
import {
|
|
|
|
|
autoUpdate,
|
|
|
|
|
flip,
|
|
|
|
|
FloatingPortal,
|
|
|
|
|
shift,
|
|
|
|
|
useClick,
|
|
|
|
|
useDismiss,
|
|
|
|
|
useFloating,
|
|
|
|
|
useInteractions,
|
|
|
|
|
} from '@floating-ui/react';
|
|
|
|
|
import React, {
|
|
|
|
|
ReactElement,
|
|
|
|
|
useCallback,
|
|
|
|
|
useEffect,
|
|
|
|
|
useId,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
} from 'react';
|
|
|
|
|
import { Manager, Popper, PopperProps, Reference } from 'react-popper';
|
|
|
|
|
import Portal from 'Components/Portal';
|
|
|
|
|
import styles from './Menu.css';
|
|
|
|
|
|
|
|
|
|
const sharedPopperOptions = {
|
|
|
|
|
modifiers: {
|
|
|
|
|
preventOverflow: {
|
|
|
|
|
padding: 0,
|
|
|
|
|
},
|
|
|
|
|
flip: {
|
|
|
|
|
padding: 0,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const popperOptions: {
|
|
|
|
|
right: Partial<PopperProps>;
|
|
|
|
|
left: Partial<PopperProps>;
|
|
|
|
|
} = {
|
|
|
|
|
right: {
|
|
|
|
|
...sharedPopperOptions,
|
|
|
|
|
placement: 'bottom-end',
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
left: {
|
|
|
|
|
...sharedPopperOptions,
|
|
|
|
|
placement: 'bottom-start',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface MenuProps {
|
|
|
|
|
className?: string;
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
@ -49,9 +30,7 @@ function Menu({
|
|
|
|
|
alignMenu = 'left',
|
|
|
|
|
enforceMaxHeight = true,
|
|
|
|
|
}: MenuProps) {
|
|
|
|
|
const updater = useRef<(() => void) | null>(null);
|
|
|
|
|
const menuButtonId = useId();
|
|
|
|
|
const menuContentId = useId();
|
|
|
|
|
const [maxHeight, setMaxHeight] = useState(0);
|
|
|
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
|
|
|
|
|
|
|
@ -70,45 +49,24 @@ function Menu({
|
|
|
|
|
setMaxHeight(height);
|
|
|
|
|
}, [menuButtonId]);
|
|
|
|
|
|
|
|
|
|
const handleWindowClick = useCallback(
|
|
|
|
|
(event: MouseEvent) => {
|
|
|
|
|
const menuButton = document.getElementById(menuButtonId);
|
|
|
|
|
const handleMenuButtonPress = useCallback(() => {
|
|
|
|
|
setIsMenuOpen((isOpen) => !isOpen);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
if (!menuButton) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const childrenArray = React.Children.toArray(children);
|
|
|
|
|
const button = React.cloneElement(childrenArray[0] as ReactElement, {
|
|
|
|
|
onPress: handleMenuButtonPress,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!menuButton.contains(event.target as Node)) {
|
|
|
|
|
setIsMenuOpen(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[menuButtonId]
|
|
|
|
|
);
|
|
|
|
|
const handleFloaterPress = useCallback((_event: MouseEvent) => {
|
|
|
|
|
// TODO: Menu items should handle closing when they are clicked.
|
|
|
|
|
// This is handled before the menu item click event is handled, so wait 100ms before closing.
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
setIsMenuOpen(false);
|
|
|
|
|
}, 100);
|
|
|
|
|
|
|
|
|
|
const handleTouchStart = useCallback(
|
|
|
|
|
(event: TouchEvent) => {
|
|
|
|
|
const menuButton = document.getElementById(menuButtonId);
|
|
|
|
|
const menuContent = document.getElementById(menuContentId);
|
|
|
|
|
|
|
|
|
|
if (!menuButton || !menuContent) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.targetTouches.length !== 1) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const target = event.targetTouches[0].target;
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
!menuButton.contains(target as Node) &&
|
|
|
|
|
!menuContent.contains(target as Node)
|
|
|
|
|
) {
|
|
|
|
|
setIsMenuOpen(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[menuButtonId, menuContentId]
|
|
|
|
|
);
|
|
|
|
|
return true;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleWindowResize = useCallback(() => {
|
|
|
|
|
updateMaxHeight();
|
|
|
|
@ -120,32 +78,15 @@ function Menu({
|
|
|
|
|
}
|
|
|
|
|
}, [isMenuOpen, updateMaxHeight]);
|
|
|
|
|
|
|
|
|
|
const handleMenuButtonPress = useCallback(() => {
|
|
|
|
|
setIsMenuOpen((isOpen) => !isOpen);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const childrenArray = React.Children.toArray(children);
|
|
|
|
|
const button = React.cloneElement(childrenArray[0] as ReactElement, {
|
|
|
|
|
onPress: handleMenuButtonPress,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (enforceMaxHeight) {
|
|
|
|
|
updateMaxHeight();
|
|
|
|
|
}
|
|
|
|
|
}, [enforceMaxHeight, updateMaxHeight]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (updater.current && isMenuOpen) {
|
|
|
|
|
updater.current();
|
|
|
|
|
}
|
|
|
|
|
}, [isMenuOpen]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
// Listen to resize events on the window and scroll events
|
|
|
|
|
// on all elements to ensure the menu is the best size possible.
|
|
|
|
|
// Listen for click events on the window to support closing the
|
|
|
|
|
// menu on clicks outside.
|
|
|
|
|
|
|
|
|
|
if (!isMenuOpen) {
|
|
|
|
|
return;
|
|
|
|
@ -153,52 +94,65 @@ function Menu({
|
|
|
|
|
|
|
|
|
|
window.addEventListener('resize', handleWindowResize);
|
|
|
|
|
window.addEventListener('scroll', handleWindowScroll, { capture: true });
|
|
|
|
|
window.addEventListener('click', handleWindowClick);
|
|
|
|
|
window.addEventListener('touchstart', handleTouchStart);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('resize', handleWindowResize);
|
|
|
|
|
window.removeEventListener('scroll', handleWindowScroll, {
|
|
|
|
|
capture: true,
|
|
|
|
|
});
|
|
|
|
|
window.removeEventListener('click', handleWindowClick);
|
|
|
|
|
window.removeEventListener('touchstart', handleTouchStart);
|
|
|
|
|
};
|
|
|
|
|
}, [
|
|
|
|
|
isMenuOpen,
|
|
|
|
|
handleWindowResize,
|
|
|
|
|
handleWindowScroll,
|
|
|
|
|
handleWindowClick,
|
|
|
|
|
handleTouchStart,
|
|
|
|
|
}, [isMenuOpen, handleWindowResize, handleWindowScroll]);
|
|
|
|
|
|
|
|
|
|
const { refs, context, floatingStyles } = useFloating({
|
|
|
|
|
middleware: [
|
|
|
|
|
flip({
|
|
|
|
|
crossAxis: false,
|
|
|
|
|
mainAxis: true,
|
|
|
|
|
}),
|
|
|
|
|
// offset({ mainAxis: 10 }),
|
|
|
|
|
shift(),
|
|
|
|
|
],
|
|
|
|
|
open: isMenuOpen,
|
|
|
|
|
placement: alignMenu === 'left' ? 'bottom-start' : 'bottom-end',
|
|
|
|
|
whileElementsMounted: autoUpdate,
|
|
|
|
|
onOpenChange: setIsMenuOpen,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const click = useClick(context);
|
|
|
|
|
const dismiss = useDismiss(context, {
|
|
|
|
|
outsidePress: handleFloaterPress,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { getReferenceProps, getFloatingProps } = useInteractions([
|
|
|
|
|
click,
|
|
|
|
|
dismiss,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Manager>
|
|
|
|
|
<Reference>
|
|
|
|
|
{({ ref }) => (
|
|
|
|
|
<div ref={ref} id={menuButtonId} className={className}>
|
|
|
|
|
{button}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Reference>
|
|
|
|
|
|
|
|
|
|
<Portal>
|
|
|
|
|
<Popper {...popperOptions[alignMenu]}>
|
|
|
|
|
{({ ref, style, scheduleUpdate }) => {
|
|
|
|
|
updater.current = scheduleUpdate;
|
|
|
|
|
|
|
|
|
|
return React.cloneElement(childrenArray[1] as ReactElement, {
|
|
|
|
|
forwardedRef: ref,
|
|
|
|
|
style: {
|
|
|
|
|
...style,
|
|
|
|
|
maxHeight,
|
|
|
|
|
},
|
|
|
|
|
isOpen: isMenuOpen,
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
</Popper>
|
|
|
|
|
</Portal>
|
|
|
|
|
</Manager>
|
|
|
|
|
<>
|
|
|
|
|
<div
|
|
|
|
|
ref={refs.setReference}
|
|
|
|
|
{...getReferenceProps()}
|
|
|
|
|
id={menuButtonId}
|
|
|
|
|
className={className}
|
|
|
|
|
>
|
|
|
|
|
{button}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{isMenuOpen ? (
|
|
|
|
|
<FloatingPortal id="portal-root">
|
|
|
|
|
{React.cloneElement(childrenArray[1] as ReactElement, {
|
|
|
|
|
forwardedRef: refs.setFloating,
|
|
|
|
|
style: {
|
|
|
|
|
maxHeight,
|
|
|
|
|
...floatingStyles,
|
|
|
|
|
},
|
|
|
|
|
isOpen: isMenuOpen,
|
|
|
|
|
...getFloatingProps(),
|
|
|
|
|
})}
|
|
|
|
|
</FloatingPortal>
|
|
|
|
|
) : null}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|