2024-04-20 01:40:41 +02:00
|
|
|
/*
|
|
|
|
|
* Copyright (C) Contributors to the Suwayomi project
|
|
|
|
|
*
|
|
|
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
|
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
|
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/*
|
2024-04-20 14:45:24 +02:00
|
|
|
* src: https://github.com/webzep/mui-nested-menu/blob/main/packages/mui-nested-menu/src/components/NestedMenuItem.tsx (2024-04-20 01:42)
|
2024-04-20 01:40:41 +02:00
|
|
|
*
|
|
|
|
|
* with a few changes to fix a bug on mobile devices where opening the sub menu immediately triggered the on click of the underlying menu item
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import Menu, { MenuProps as MuiMenuProps } from '@mui/material/Menu';
|
|
|
|
|
import { MenuItemProps as MuiMenuItemProps } from '@mui/material/MenuItem';
|
|
|
|
|
import {
|
|
|
|
|
ElementType,
|
|
|
|
|
forwardRef,
|
|
|
|
|
HTMLAttributes,
|
|
|
|
|
KeyboardEvent,
|
|
|
|
|
FocusEvent,
|
|
|
|
|
MouseEvent,
|
|
|
|
|
ReactNode,
|
|
|
|
|
RefAttributes,
|
|
|
|
|
useImperativeHandle,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
Ref,
|
|
|
|
|
} from 'react';
|
|
|
|
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
2024-04-26 12:53:31 +02:00
|
|
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
2024-04-20 01:40:41 +02:00
|
|
|
import Box from '@mui/material/Box';
|
|
|
|
|
|
|
|
|
|
import { OverridableComponent } from '@mui/material/OverridableComponent';
|
|
|
|
|
import { SvgIconTypeMap } from '@mui/material/SvgIcon';
|
2024-10-05 16:09:34 +02:00
|
|
|
import { IconMenuItem } from '@/modules/core/components/menu/IconMenuItem.tsx';
|
2024-09-30 02:27:10 +02:00
|
|
|
import { getOptionForDirection } from '@/theme.tsx';
|
2024-05-23 02:29:08 +02:00
|
|
|
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';
|
2024-04-20 01:40:41 +02:00
|
|
|
|
|
|
|
|
export type NestedMenuItemProps = Omit<MuiMenuItemProps, 'button'> & {
|
|
|
|
|
parentMenuOpen: boolean;
|
|
|
|
|
component?: ElementType;
|
|
|
|
|
label?: string;
|
|
|
|
|
renderLabel?: () => ReactNode;
|
|
|
|
|
RightIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
|
|
|
|
|
LeftIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
|
|
|
|
|
children?: ReactNode;
|
|
|
|
|
className?: string;
|
|
|
|
|
tabIndex?: number;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
ContainerProps?: HTMLAttributes<HTMLElement> & RefAttributes<HTMLElement>;
|
|
|
|
|
MenuProps?: Partial<Omit<MuiMenuProps, 'children'>>;
|
|
|
|
|
button?: true | undefined;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const NestedMenuItem = forwardRef<HTMLLIElement | null, NestedMenuItemProps>((props, ref) => {
|
|
|
|
|
const {
|
|
|
|
|
parentMenuOpen,
|
|
|
|
|
label,
|
|
|
|
|
renderLabel,
|
2024-04-26 12:53:31 +02:00
|
|
|
RightIcon = getOptionForDirection(ChevronRightIcon, ChevronLeftIcon),
|
2024-04-20 01:40:41 +02:00
|
|
|
LeftIcon,
|
|
|
|
|
children,
|
|
|
|
|
className,
|
|
|
|
|
tabIndex: tabIndexProp,
|
|
|
|
|
ContainerProps: ContainerPropsProp = {},
|
|
|
|
|
MenuProps,
|
|
|
|
|
...MenuItemProps
|
|
|
|
|
} = props;
|
|
|
|
|
|
2024-05-23 02:29:08 +02:00
|
|
|
const isTouchDevice = MediaQuery.useIsTouchDevice();
|
|
|
|
|
|
2024-04-20 01:40:41 +02:00
|
|
|
const { ref: containerRefProp, ...ContainerProps } = ContainerPropsProp;
|
|
|
|
|
|
|
|
|
|
const menuItemRef = useRef<HTMLLIElement | null>(null);
|
|
|
|
|
useImperativeHandle(ref, () => menuItemRef.current!);
|
|
|
|
|
|
|
|
|
|
const containerRef = useRef<HTMLElement>(null);
|
|
|
|
|
useImperativeHandle(containerRefProp as Ref<HTMLElement | null>, () => containerRef.current as HTMLElement);
|
|
|
|
|
|
|
|
|
|
const menuContainerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
|
|
|
|
const [isSubMenuOpen, setIsSubMenuOpen] = useState(false);
|
|
|
|
|
|
2024-04-29 01:06:22 +02:00
|
|
|
const changeMenuOpenState = (open: boolean) => {
|
|
|
|
|
if (isSubMenuOpen === open) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (props.disabled) {
|
|
|
|
|
setIsSubMenuOpen(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsSubMenuOpen(open);
|
|
|
|
|
};
|
|
|
|
|
|
2024-04-20 01:40:41 +02:00
|
|
|
const handleMouseEnter = (e: MouseEvent<HTMLElement>) => {
|
2024-05-23 02:29:08 +02:00
|
|
|
if (isTouchDevice) {
|
2024-04-20 01:40:41 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-29 01:06:22 +02:00
|
|
|
changeMenuOpenState(true);
|
2024-04-20 01:40:41 +02:00
|
|
|
|
|
|
|
|
if (ContainerProps.onMouseEnter) {
|
|
|
|
|
ContainerProps.onMouseEnter(e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const handleMouseLeave = (e: MouseEvent<HTMLElement>) => {
|
2024-04-29 01:06:22 +02:00
|
|
|
changeMenuOpenState(false);
|
2024-04-20 01:40:41 +02:00
|
|
|
|
|
|
|
|
if (ContainerProps.onMouseLeave) {
|
|
|
|
|
ContainerProps.onMouseLeave(e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check if any immediate children are active
|
|
|
|
|
const isSubmenuFocused = () => {
|
|
|
|
|
const active = containerRef.current?.ownerDocument.activeElement ?? null;
|
|
|
|
|
if (menuContainerRef.current == null) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
for (const child of menuContainerRef.current.children) {
|
|
|
|
|
if (child === active) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleFocus = (e: FocusEvent<HTMLElement>) => {
|
2024-05-23 02:29:08 +02:00
|
|
|
if (isTouchDevice) {
|
2024-04-20 01:40:41 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (e.target === containerRef.current) {
|
2024-04-29 01:06:22 +02:00
|
|
|
changeMenuOpenState(true);
|
2024-04-20 01:40:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (ContainerProps.onFocus) {
|
|
|
|
|
ContainerProps.onFocus(e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleClick = (e: MouseEvent<HTMLElement>) => {
|
2024-04-29 01:06:22 +02:00
|
|
|
changeMenuOpenState(!isSubMenuOpen);
|
2024-04-20 01:40:41 +02:00
|
|
|
|
|
|
|
|
if (ContainerProps.onClick) {
|
|
|
|
|
ContainerProps.onClick(e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isSubmenuFocused()) {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const active = containerRef.current?.ownerDocument.activeElement;
|
|
|
|
|
|
|
|
|
|
if (e.key === 'ArrowLeft' && isSubmenuFocused()) {
|
|
|
|
|
containerRef.current?.focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (e.key === 'ArrowRight' && e.target === containerRef.current && e.target === active) {
|
|
|
|
|
const firstChild = menuContainerRef.current?.children[0] as HTMLDivElement;
|
|
|
|
|
firstChild?.focus();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const open = isSubMenuOpen && parentMenuOpen;
|
|
|
|
|
|
|
|
|
|
// Root element must have a `tabIndex` attribute for keyboard navigation
|
|
|
|
|
let tabIndex;
|
|
|
|
|
if (!props.disabled) {
|
|
|
|
|
tabIndex = tabIndexProp !== undefined ? tabIndexProp : -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Box
|
|
|
|
|
{...ContainerProps}
|
|
|
|
|
ref={containerRef}
|
|
|
|
|
onFocus={handleFocus}
|
|
|
|
|
onClick={handleClick}
|
|
|
|
|
tabIndex={tabIndex}
|
|
|
|
|
onMouseEnter={handleMouseEnter}
|
|
|
|
|
onMouseLeave={handleMouseLeave}
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
>
|
|
|
|
|
<IconMenuItem
|
|
|
|
|
MenuItemProps={MenuItemProps}
|
|
|
|
|
className={className}
|
|
|
|
|
ref={menuItemRef}
|
|
|
|
|
LeftIcon={LeftIcon}
|
|
|
|
|
RightIcon={RightIcon}
|
|
|
|
|
label={label}
|
|
|
|
|
renderLabel={renderLabel}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<Menu
|
|
|
|
|
// Set pointer events to 'none' to prevent the invisible Popover div
|
|
|
|
|
// from capturing events for clicks and hovers
|
|
|
|
|
style={{ pointerEvents: 'none' }}
|
|
|
|
|
anchorEl={menuItemRef.current}
|
|
|
|
|
anchorOrigin={{
|
2024-04-26 12:53:31 +02:00
|
|
|
horizontal: getOptionForDirection('right', 'left'),
|
2024-04-20 01:40:41 +02:00
|
|
|
vertical: 'top',
|
|
|
|
|
}}
|
|
|
|
|
transformOrigin={{
|
2024-04-26 12:53:31 +02:00
|
|
|
horizontal: getOptionForDirection('left', 'right'),
|
2024-04-20 01:40:41 +02:00
|
|
|
vertical: 'top',
|
|
|
|
|
}}
|
|
|
|
|
open={open}
|
|
|
|
|
autoFocus={false}
|
|
|
|
|
disableAutoFocus
|
|
|
|
|
disableEnforceFocus
|
|
|
|
|
onClose={() => {
|
2024-04-29 01:06:22 +02:00
|
|
|
changeMenuOpenState(false);
|
2024-04-20 01:40:41 +02:00
|
|
|
}}
|
|
|
|
|
{...MenuProps}
|
|
|
|
|
>
|
|
|
|
|
<Box ref={menuContainerRef} style={{ pointerEvents: 'auto' }}>
|
|
|
|
|
{children}
|
|
|
|
|
</Box>
|
|
|
|
|
</Menu>
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
NestedMenuItem.displayName = 'NestedMenuItem';
|
|
|
|
|
export { NestedMenuItem };
|