Files
fluxer/fluxer_app/src/components/uikit/ContextMenu/ContextMenu.tsx
Hampus Kraft 2f557eda8c initial commit
2026-01-01 21:05:54 +00:00

483 lines
14 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import type {PressEvent} from 'react-aria-components';
import {
Menu as AriaMenu,
MenuItem as AriaMenuItem,
Popover as AriaPopover,
MenuSection as AriaSection,
Separator as AriaSeparator,
SubmenuTrigger as AriaSubmenuTrigger,
} from 'react-aria-components';
import {createPortal} from 'react-dom';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import type {ContextMenu as ContextMenuType} from '~/stores/ContextMenuStore';
import ContextMenuStore from '~/stores/ContextMenuStore';
import LayerManager from '~/stores/LayerManager';
import {isMobileExperienceEnabled} from '~/utils/mobileExperience';
import {isScrollbarDragActive} from '~/utils/ScrollbarDragState';
import styles from './ContextMenu.module.css';
const ContextMenuCloseContext = React.createContext<() => void>(() => {});
export const useContextMenuClose = () => React.useContext(ContextMenuCloseContext);
export const ContextMenuCloseProvider = ContextMenuCloseContext.Provider;
interface RootContextMenuProps {
contextMenu: ContextMenuType;
}
const RootContextMenuInner: React.FC<RootContextMenuProps> = observer(({contextMenu}) => {
const [isOpen, setIsOpen] = React.useState(true);
const [isPositioned, setIsPositioned] = React.useState(false);
const [position, setPosition] = React.useState({x: 0, y: 0});
const menuRef = React.useRef<HTMLDivElement>(null);
const menuContentRef = React.useRef<HTMLDivElement>(null);
const rafIdRef = React.useRef<number | null>(null);
const focusFirstMenuItem = React.useCallback(() => {
const menuElement = menuContentRef.current;
if (!menuElement) {
return;
}
const firstInteractable = menuElement.querySelector<HTMLElement>(
[
'[role="menuitem"]:not([aria-disabled="true"])',
'[role="menuitemcheckbox"]:not([aria-disabled="true"])',
'[role="menuitemradio"]:not([aria-disabled="true"])',
].join(', '),
);
(firstInteractable ?? menuElement).focus({preventScroll: true});
}, []);
const close = React.useCallback(() => {
setIsOpen(false);
ContextMenuActionCreators.close();
}, []);
React.useLayoutEffect(() => {
const {x, y} = contextMenu.target;
const align = contextMenu.config?.align ?? 'top-left';
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
rafIdRef.current = requestAnimationFrame(() => {
if (menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const cursorOffset = 4;
const edgePadding = 12;
let finalX: number;
let finalY = y + cursorOffset;
if (align === 'top-right') {
finalX = x - rect.width;
if (finalX < edgePadding) {
finalX = x + cursorOffset;
if (finalX + rect.width > viewportWidth - edgePadding) {
finalX = Math.max(edgePadding, viewportWidth - rect.width - edgePadding);
}
}
} else {
finalX = x + cursorOffset;
if (finalX + rect.width > viewportWidth - edgePadding) {
finalX = x - rect.width - cursorOffset;
if (finalX < edgePadding) {
finalX = Math.max(edgePadding, viewportWidth - rect.width - edgePadding);
}
}
}
if (finalY + rect.height > viewportHeight - edgePadding) {
finalY = y - rect.height - cursorOffset;
if (finalY < edgePadding) {
finalY = Math.max(edgePadding, viewportHeight - rect.height - edgePadding);
}
}
finalX = Math.max(edgePadding, finalX);
finalY = Math.max(edgePadding, finalY);
setPosition({x: finalX, y: finalY});
}
setIsPositioned(true);
rafIdRef.current = null;
});
return () => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
};
}, [contextMenu.target, contextMenu.config?.align]);
React.useEffect(() => {
if (isOpen) {
LayerManager.addLayer('contextmenu', contextMenu.id, close);
return () => {
LayerManager.removeLayer('contextmenu', contextMenu.id);
};
}
return;
}, [isOpen, contextMenu.id, close]);
const handleBackdropClick = React.useCallback(() => {
if (isScrollbarDragActive()) {
return;
}
close();
}, [close]);
React.useLayoutEffect(() => {
if (!isOpen || !isPositioned) {
return;
}
focusFirstMenuItem();
}, [isOpen, isPositioned, contextMenu.id, focusFirstMenuItem]);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
close();
return;
}
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
const menuElement = menuContentRef.current;
if (!menuElement) return;
const menuItems = menuElement.querySelectorAll<HTMLElement>('[role="menuitem"]');
const pressedKey = e.key.toLowerCase();
for (const item of menuItems) {
const shortcutElement =
item.querySelector(`.${styles.itemShortcut}`) || item.querySelector('[class*="shortcut"]');
if (shortcutElement) {
const shortcutText = shortcutElement.textContent?.toLowerCase().trim();
if (shortcutText === pressedKey) {
e.preventDefault();
e.stopPropagation();
item.click();
return;
}
}
}
}
};
const handleClickOutside = (e: MouseEvent) => {
if (isScrollbarDragActive()) {
return;
}
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
close();
}
};
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [close]);
if (!isOpen) {
return null;
}
const {x, y} = contextMenu.target;
return (
<div className={styles.contextMenuOverlay} data-overlay-pass-through="true">
<div className={styles.backdrop} onClick={handleBackdropClick} aria-hidden="true" />
<div
ref={menuRef}
className={styles.contextMenu}
style={{
position: 'fixed',
left: `${isPositioned ? position.x : x}px`,
top: `${isPositioned ? position.y : y}px`,
opacity: isPositioned ? 1 : 0,
visibility: isPositioned ? 'visible' : 'hidden',
pointerEvents: isPositioned ? 'auto' : 'none',
zIndex: 'var(--z-index-contextmenu)',
}}
>
<ContextMenuCloseContext.Provider value={close}>
<AriaMenu ref={menuContentRef} className={styles.ariaMenu} aria-label="Context menu">
{contextMenu.render({onClose: close})}
</AriaMenu>
</ContextMenuCloseContext.Provider>
</div>
</div>
);
});
export const RootContextMenu: React.FC<RootContextMenuProps> = observer(({contextMenu}) => {
return <RootContextMenuInner contextMenu={contextMenu} />;
});
interface MenuItemProps {
label: string;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onSelect?: (event: PressEvent) => void;
icon?: React.ReactNode;
danger?: boolean;
color?: string;
className?: string;
children?: React.ReactNode;
closeOnSelect?: boolean;
shortcut?: string;
}
export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
({label, disabled, onSelect, icon, danger, className, children, closeOnSelect = true, shortcut}, forwardedRef) => {
const closeMenu = React.useContext(ContextMenuCloseContext);
const handlePress = React.useCallback(
(event: PressEvent) => {
if (disabled) return;
onSelect?.(event);
if (closeOnSelect) {
closeMenu();
}
},
[closeMenu, closeOnSelect, disabled, onSelect],
);
return (
<AriaMenuItem
ref={forwardedRef}
onPress={handlePress}
isDisabled={disabled}
className={clsx(styles.item, className, {
[styles.danger]: danger,
[styles.disabled]: disabled,
})}
textValue={label || (typeof children === 'string' ? children : '')}
>
{icon && <div className={styles.itemIcon}>{icon}</div>}
<div className={styles.itemLabel}>{children || label}</div>
{shortcut && <div className={styles.itemShortcut}>{shortcut}</div>}
</AriaMenuItem>
);
},
);
MenuItem.displayName = 'MenuItem';
interface SubMenuProps {
label: string;
icon?: React.ReactNode;
disabled?: boolean;
hint?: string;
children?: React.ReactNode;
onTriggerSelect?: () => void;
selectionMode?: 'none' | 'single' | 'multiple';
}
export const SubMenu = React.forwardRef<HTMLDivElement, SubMenuProps>(
({label, icon, disabled, hint, children, onTriggerSelect, selectionMode}, forwardedRef) => {
const handleTriggerPress = React.useCallback(() => {
if (disabled) return;
onTriggerSelect?.();
}, [disabled, onTriggerSelect]);
const handleLabelClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (disabled || !onTriggerSelect) return;
const target = event.target as HTMLElement;
if (target.closest('[data-submenu-caret="true"]')) {
return;
}
event.preventDefault();
event.stopPropagation();
onTriggerSelect();
},
[disabled, onTriggerSelect],
);
const handleLabelKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled || !onTriggerSelect) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onTriggerSelect();
}
},
[disabled, onTriggerSelect],
);
return (
<AriaSubmenuTrigger>
<AriaMenuItem
ref={forwardedRef}
isDisabled={disabled}
className={clsx(styles.item, {
[styles.disabled]: disabled,
})}
textValue={label}
onAction={onTriggerSelect ? handleTriggerPress : undefined}
>
{icon && <div className={styles.itemIcon}>{icon}</div>}
<div
className={clsx(styles.itemLabelContainer)}
onClick={handleLabelClick}
onKeyDown={handleLabelKeyDown}
role="button"
tabIndex={-1}
>
<div className={styles.itemLabelText}>{label}</div>
{hint && <div className={styles.itemHint}>{hint}</div>}
</div>
<svg
className={styles.submenuCaret}
width="16"
height="16"
viewBox="0 0 256 256"
aria-hidden="true"
data-submenu-caret="true"
>
<path
fill="currentColor"
d="M184.49 136.49l-80 80a12 12 0 0 1-17-17L159 128L87.51 56.49a12 12 0 1 1 17-17l80 80a12 12 0 0 1-.02 17"
/>
</svg>
</AriaMenuItem>
<AriaPopover placement="right top" offset={4} className={styles.submenuPopover}>
<AriaMenu className={styles.ariaMenu} aria-label="Submenu" autoFocus="first" selectionMode={selectionMode}>
{children}
</AriaMenu>
</AriaPopover>
</AriaSubmenuTrigger>
);
},
);
SubMenu.displayName = 'SubMenu';
export const MenuSeparator: React.FC = observer(() => {
return <AriaSeparator className={styles.separator} />;
});
interface CheckboxItemProps {
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
icon?: React.ReactNode;
children?: React.ReactNode;
danger?: boolean;
closeOnChange?: boolean;
}
export const CheckboxItem = React.forwardRef<HTMLDivElement, CheckboxItemProps>(
(
{label, checked, onCheckedChange, disabled, icon, children, danger = false, closeOnChange = false},
forwardedRef,
) => {
const closeMenu = React.useContext(ContextMenuCloseContext);
const handleAction = React.useCallback(
(_e: PressEvent) => {
if (disabled) return;
onCheckedChange(!checked);
if (closeOnChange) {
closeMenu();
}
},
[checked, closeMenu, closeOnChange, disabled, onCheckedChange],
);
return (
<AriaMenuItem
ref={forwardedRef}
onPress={handleAction}
isDisabled={disabled}
className={clsx(styles.item, styles.checkboxItem, {
[styles.disabled]: disabled,
[styles.danger]: danger,
})}
textValue={label || (typeof children === 'string' ? children : '')}
>
{icon && <div className={styles.itemIcon}>{icon}</div>}
<div className={styles.itemLabel}>{children || label}</div>
<div className={styles.checkboxIndicator}>
<div
className={clsx(styles.checkbox, {
[styles.checkboxChecked]: checked,
})}
/>
</div>
</AriaMenuItem>
);
},
);
CheckboxItem.displayName = 'CheckboxItem';
interface MenuGroupProps {
label?: string;
children?: React.ReactNode;
}
export const MenuGroup: React.FC<MenuGroupProps> = observer(({children}) => {
const validChildren = React.Children.toArray(children).filter((child): child is React.ReactElement => {
if (!React.isValidElement(child)) return false;
if (child.type === React.Fragment && !(child.props as {children?: React.ReactNode}).children) return false;
return true;
});
if (validChildren.length === 0) {
return null;
}
return <AriaSection className={styles.group}>{validChildren}</AriaSection>;
});
export const ContextMenu: React.FC = observer(() => {
if (isMobileExperienceEnabled()) {
return null;
}
const contextMenu = ContextMenuStore.contextMenu;
if (!contextMenu) return null;
return createPortal(<RootContextMenu key={contextMenu.id} contextMenu={contextMenu} />, document.body);
});