Move "core" folder out of "features" into "base"
This commit is contained in:
51
src/base/components/menu/IconMenuItem.tsx
Normal file
51
src/base/components/menu/IconMenuItem.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/*
|
||||
* src: https://github.com/webzep/mui-nested-menu/blob/main/packages/mui-nested-menu/src/components/IconMenuItem.tsx (2024-04-20 01:42)
|
||||
*/
|
||||
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import MenuItem, { MenuItemProps as MuiMenuItemProps } from '@mui/material/MenuItem';
|
||||
import { SxProps, Theme } from '@mui/material/styles';
|
||||
import React, { forwardRef, RefObject } from 'react';
|
||||
|
||||
import { OverridableComponent } from '@mui/material/OverridableComponent';
|
||||
import { SvgIconTypeMap } from '@mui/material/SvgIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
|
||||
type IconMenuItemProps = {
|
||||
MenuItemProps?: MuiMenuItemProps;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
renderLabel?: () => React.ReactNode;
|
||||
LeftIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
ref?: RefObject<HTMLLIElement | null>;
|
||||
RightIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
|
||||
sx?: SxProps<Theme>;
|
||||
};
|
||||
|
||||
export const IconMenuItem = forwardRef<HTMLLIElement, IconMenuItemProps>(
|
||||
({ MenuItemProps, className, label, LeftIcon, renderLabel, RightIcon, ...props }, ref) => (
|
||||
<MenuItem {...MenuItemProps} ref={ref} className={className} {...props}>
|
||||
{LeftIcon && (
|
||||
<ListItemIcon>
|
||||
<LeftIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
<ListItemText>{label}</ListItemText>
|
||||
{RightIcon && (
|
||||
<ListItemIcon style={{ minWidth: 0 }}>
|
||||
<RightIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
</MenuItem>
|
||||
),
|
||||
);
|
||||
35
src/base/components/menu/Menu.tsx
Normal file
35
src/base/components/menu/Menu.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
import MuiMenu, { MenuProps } from '@mui/material/Menu';
|
||||
import { useState, type JSX } from 'react';
|
||||
|
||||
export const Menu = ({
|
||||
children,
|
||||
onClose,
|
||||
...props
|
||||
}: Omit<MenuProps, 'children' | 'onClose'> &
|
||||
Required<Pick<MenuProps, 'onClose'>> & {
|
||||
children: (onClose: () => void, setHideMenu: (hide: boolean) => void) => JSX.Element | JSX.Element[];
|
||||
}) => {
|
||||
const [shouldHideMenu, setShouldHideMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<MuiMenu
|
||||
{...props}
|
||||
open={props.open}
|
||||
onClose={onClose}
|
||||
sx={{ visibility: !props.open || shouldHideMenu ? 'hidden' : 'visible' }}
|
||||
>
|
||||
{children(() => {
|
||||
onClose({}, 'backdropClick');
|
||||
setShouldHideMenu(false);
|
||||
}, setShouldHideMenu)}
|
||||
</MuiMenu>
|
||||
);
|
||||
};
|
||||
43
src/base/components/menu/Menu.utils.ts
Normal file
43
src/base/components/menu/Menu.utils.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
import { t as translate } from 'i18next';
|
||||
|
||||
import { TranslationKey } from '@/base/Base.types.ts';
|
||||
|
||||
export const createGetMenuItemTitle =
|
||||
<Action extends string>(
|
||||
isSingleMode: boolean,
|
||||
actionToTranslationKey: Record<
|
||||
Action,
|
||||
{
|
||||
action: {
|
||||
single: TranslationKey;
|
||||
selected: TranslationKey;
|
||||
};
|
||||
success: TranslationKey;
|
||||
error: TranslationKey;
|
||||
}
|
||||
>,
|
||||
) =>
|
||||
(action: Action, count: number): string => {
|
||||
const countSuffix = count > 0 ? ` (${count})` : '';
|
||||
return `${translate(
|
||||
actionToTranslationKey[action].action[isSingleMode ? 'single' : 'selected'],
|
||||
)}${countSuffix}`;
|
||||
};
|
||||
|
||||
export const createShouldShowMenuItem =
|
||||
(isSingleMode: boolean) =>
|
||||
(shouldBeVisible: boolean = false): boolean =>
|
||||
isSingleMode ? shouldBeVisible : true;
|
||||
|
||||
export const createIsMenuItemDisabled =
|
||||
(isSingleMode: boolean) =>
|
||||
(shouldBeDisabled: boolean): boolean =>
|
||||
isSingleMode ? false : shouldBeDisabled;
|
||||
27
src/base/components/menu/MenuItem.tsx
Normal file
27
src/base/components/menu/MenuItem.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import MuiMenuItem, { MenuItemProps } from '@mui/material/MenuItem';
|
||||
import { OverridableComponent } from '@mui/material/OverridableComponent';
|
||||
import { SvgIconTypeMap } from '@mui/material/SvgIcon';
|
||||
|
||||
interface IProps extends MenuItemProps {
|
||||
title: string;
|
||||
Icon: OverridableComponent<SvgIconTypeMap> & { muiName: string };
|
||||
}
|
||||
|
||||
export const MenuItem = ({ title, Icon, ...menuItemProps }: IProps) => (
|
||||
<MuiMenuItem {...menuItemProps}>
|
||||
<ListItemIcon>
|
||||
<Icon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{title}</ListItemText>
|
||||
</MuiMenuItem>
|
||||
);
|
||||
235
src/base/components/menu/NestedMenuItem.tsx
Normal file
235
src/base/components/menu/NestedMenuItem.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/*
|
||||
* src: https://github.com/webzep/mui-nested-menu/blob/main/packages/mui-nested-menu/src/components/NestedMenuItem.tsx (2024-04-20 01:42)
|
||||
*
|
||||
* 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,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import { OverridableComponent } from '@mui/material/OverridableComponent';
|
||||
import { SvgIconTypeMap } from '@mui/material/SvgIcon';
|
||||
import { useMergedRef } from '@mantine/hooks';
|
||||
import { IconMenuItem } from '@/base/components/menu/IconMenuItem.tsx';
|
||||
import { getOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
|
||||
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||
|
||||
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,
|
||||
RightIcon = getOptionForDirection(ChevronRightIcon, ChevronLeftIcon),
|
||||
LeftIcon,
|
||||
children,
|
||||
className,
|
||||
tabIndex: tabIndexProp,
|
||||
ContainerProps: ContainerPropsProp = {},
|
||||
MenuProps,
|
||||
...MenuItemProps
|
||||
} = props;
|
||||
|
||||
const isTouchDevice = MediaQuery.useIsTouchDevice();
|
||||
|
||||
const { ref: containerRefProp, ...ContainerProps } = ContainerPropsProp;
|
||||
|
||||
const menuItemRef = useRef<HTMLLIElement | null>(null);
|
||||
const mergedMenuItemRef = useMergedRef(ref, menuItemRef);
|
||||
|
||||
const containerRef = useRef<HTMLElement>(null);
|
||||
const mergedContainerRef = useMergedRef(containerRefProp, containerRef);
|
||||
|
||||
const menuContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [isSubMenuOpen, setIsSubMenuOpen] = useState(false);
|
||||
|
||||
const changeMenuOpenState = (open: boolean) => {
|
||||
if (isSubMenuOpen === open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.disabled) {
|
||||
setIsSubMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubMenuOpen(open);
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: MouseEvent<HTMLElement>) => {
|
||||
if (isTouchDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
changeMenuOpenState(true);
|
||||
|
||||
if (ContainerProps.onMouseEnter) {
|
||||
ContainerProps.onMouseEnter(e);
|
||||
}
|
||||
};
|
||||
const handleMouseLeave = (e: MouseEvent<HTMLElement>) => {
|
||||
changeMenuOpenState(false);
|
||||
|
||||
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>) => {
|
||||
if (isTouchDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === containerRef.current) {
|
||||
changeMenuOpenState(true);
|
||||
}
|
||||
|
||||
if (ContainerProps.onFocus) {
|
||||
ContainerProps.onFocus(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent<HTMLElement>) => {
|
||||
changeMenuOpenState(!isSubMenuOpen);
|
||||
|
||||
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={mergedContainerRef}
|
||||
onFocus={handleFocus}
|
||||
onClick={handleClick}
|
||||
tabIndex={tabIndex}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<IconMenuItem
|
||||
MenuItemProps={MenuItemProps}
|
||||
className={className}
|
||||
ref={mergedMenuItemRef}
|
||||
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={{
|
||||
horizontal: getOptionForDirection('right', 'left'),
|
||||
vertical: 'top',
|
||||
}}
|
||||
transformOrigin={{
|
||||
horizontal: getOptionForDirection('left', 'right'),
|
||||
vertical: 'top',
|
||||
}}
|
||||
open={open}
|
||||
autoFocus={false}
|
||||
disableAutoFocus
|
||||
disableEnforceFocus
|
||||
onClose={() => {
|
||||
changeMenuOpenState(false);
|
||||
}}
|
||||
{...MenuProps}
|
||||
>
|
||||
<Box ref={menuContainerRef} style={{ pointerEvents: 'auto' }}>
|
||||
{children}
|
||||
</Box>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
NestedMenuItem.displayName = 'NestedMenuItem';
|
||||
export { NestedMenuItem };
|
||||
Reference in New Issue
Block a user