add new context menu implementation

This commit is contained in:
jeffvli
2025-11-15 04:22:06 -08:00
parent ec0590c79a
commit 8eb90ebf06
47 changed files with 2826 additions and 1593 deletions
@@ -0,0 +1,109 @@
.content {
z-index: 1000;
width: 20rem;
min-width: 20rem;
max-width: 20rem;
padding: var(--theme-spacing-xs);
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
border: 1px solid var(--theme-colors-border);
border-radius: var(--theme-radius-md);
filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%));
}
.inner-content {
display: flex;
flex-direction: column;
gap: var(--base-gap-xs);
overflow: hidden;
}
.item {
position: relative;
display: flex;
align-items: center;
min-width: 8rem;
max-width: 100%;
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
font-size: var(--theme-font-size-sm);
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
cursor: default;
user-select: none;
&.has-left-icon {
padding-left: calc(var(--theme-spacing-md) + 1.5rem);
}
&.has-right-icon {
padding-right: calc(var(--theme-spacing-md) + 1.5rem);
}
& > *:not(.left-icon, .right-icon) {
flex: 1;
min-width: 0;
word-wrap: break-word;
overflow-wrap: break-word;
}
&:hover {
background: var(--theme-colors-surface);
}
}
.item[data-highlighted] {
background: var(--theme-colors-surface);
}
.left-icon {
position: absolute;
top: 50%;
left: var(--theme-spacing-md);
z-index: 1;
display: flex;
align-items: center;
transform: translateY(-50%);
}
.right-icon {
position: absolute;
top: 50%;
right: var(--theme-spacing-md);
z-index: 1;
display: flex;
align-items: center;
transform: translateY(-50%);
}
.disabled {
pointer-events: none;
opacity: 0.6;
}
.item.selected {
&::before {
position: absolute;
top: 50%;
left: 2px;
width: 4px;
height: 50%;
content: '';
background-color: var(--theme-colors-primary-filled);
border-radius: var(--theme-border-radius-xl);
transform: translateY(-50%);
}
}
.divider {
height: 1px;
padding: 0;
margin: var(--theme-spacing-xs) 0;
background: none;
border: none;
border-top: 1px solid var(--theme-colors-border);
}
.max-height {
max-height: 36rem;
}
@@ -0,0 +1,235 @@
import type { Dispatch, SetStateAction } from 'react';
import * as RadixContextMenu from '@radix-ui/react-context-menu';
import clsx from 'clsx';
import { AnimatePresence, motion } from 'motion/react';
import { createContext, Fragment, type ReactNode, useContext, useMemo, useState } from 'react';
import styles from './context-menu.module.css';
import { animationVariants } from '/@/shared/components/animations/animation-variants';
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
interface ContextMenuContext {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}
export const ContextMenuContext = createContext<ContextMenuContext | null>(null);
interface ContentProps {
children: ReactNode;
onCloseAutoFocus?: (event: FocusEvent) => void;
onEscapeKeyDown?: (event: KeyboardEvent) => void;
onFocusOutside?: (event: FocusEvent) => void;
onPointerDownOutside?: (event: PointerEvent) => void;
stickyContent?: ReactNode;
}
interface ContextMenuProps {
children: ReactNode;
}
interface DividerProps {}
interface ItemProps {
children: ReactNode;
className?: string;
disabled?: boolean;
isSelected?: boolean;
leftIcon?: keyof typeof AppIcon;
onSelect?: (event: Event) => void;
rightIcon?: keyof typeof AppIcon;
}
interface LabelProps extends React.ComponentPropsWithoutRef<'div'> {
children: ReactNode;
}
interface SubmenuContext {
disabled?: boolean;
isCloseDisabled?: boolean;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}
interface TargetProps {
children: ReactNode;
}
export function ContextMenu(props: ContextMenuProps) {
const { children } = props;
const [open, setOpen] = useState(false);
const context = useMemo(() => ({ open, setOpen }), [open]);
return (
<RadixContextMenu.Root onOpenChange={setOpen}>
<ContextMenuContext.Provider value={context}>{children}</ContextMenuContext.Provider>
</RadixContextMenu.Root>
);
}
function Content(props: ContentProps) {
const { children, stickyContent } = props;
const { open } = useContext(ContextMenuContext) as ContextMenuContext;
return (
<AnimatePresence>
{open && (
<RadixContextMenu.Portal forceMount>
<RadixContextMenu.Content asChild className={styles.content}>
<motion.div
animate="show"
className={styles.content}
exit="hidden"
initial="hidden"
>
{stickyContent}
<ScrollArea className={styles.maxHeight}>{children}</ScrollArea>
</motion.div>
</RadixContextMenu.Content>
</RadixContextMenu.Portal>
)}
</AnimatePresence>
);
}
function Divider(props: DividerProps) {
return <RadixContextMenu.Separator {...props} className={styles.divider} />;
}
function Item(props: ItemProps) {
const { children, className, disabled, isSelected, leftIcon, onSelect, rightIcon } = props;
return (
<RadixContextMenu.Item
className={clsx(styles.item, className, {
[styles.disabled]: disabled,
[styles.selected]: isSelected,
[styles['has-left-icon']]: !!leftIcon,
[styles['has-right-icon']]: !!rightIcon,
})}
disabled={disabled}
onSelect={onSelect}
>
{leftIcon && <Icon className={styles.leftIcon} icon={leftIcon} />}
{children}
{rightIcon && <Icon className={styles.rightIcon} icon={rightIcon} />}
</RadixContextMenu.Item>
);
}
function Label(props: LabelProps) {
const { children, className, ...htmlProps } = props;
return (
<RadixContextMenu.Label className={clsx(styles.label, className)} {...htmlProps}>
{children}
</RadixContextMenu.Label>
);
}
function Target(props: TargetProps) {
const { children } = props;
return (
<RadixContextMenu.Trigger asChild className={styles.target}>
{children}
</RadixContextMenu.Trigger>
);
}
const SubmenuContext = createContext<null | SubmenuContext>(null);
interface SubmenuContentProps {
children: ReactNode;
stickyContent?: ReactNode;
}
interface SubmenuProps {
children: ReactNode;
disabled?: boolean;
isCloseDisabled?: boolean;
open?: boolean;
}
interface SubmenuTargetProps {
children: ReactNode;
}
function Submenu(props: SubmenuProps) {
const { children, disabled, isCloseDisabled, open: isManuallyOpen } = props;
const [open, setOpen] = useState(isManuallyOpen ?? false);
const context = useMemo(
() => ({ disabled, isCloseDisabled, open, setOpen }),
[disabled, isCloseDisabled, open],
);
return (
<RadixContextMenu.Sub open={open}>
<SubmenuContext.Provider value={context}>{children}</SubmenuContext.Provider>
</RadixContextMenu.Sub>
);
}
function SubmenuContent(props: SubmenuContentProps) {
const { children, stickyContent } = props;
const { isCloseDisabled, open, setOpen } = useContext(SubmenuContext) as SubmenuContext;
return (
<Fragment>
{open && (
<RadixContextMenu.Portal forceMount>
<RadixContextMenu.SubContent
className={styles.content}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => {
if (!isCloseDisabled) {
setOpen(false);
}
}}
>
<motion.div
animate="show"
className={styles.innerContent}
initial="hidden"
variants={animationVariants.fadeIn}
>
{stickyContent}
<ScrollArea className={styles.maxHeight}>{children}</ScrollArea>
</motion.div>
</RadixContextMenu.SubContent>
</RadixContextMenu.Portal>
)}
</Fragment>
);
}
function SubmenuTarget(props: SubmenuTargetProps) {
const { children } = props;
const { disabled, setOpen } = useContext(SubmenuContext) as SubmenuContext;
return (
<RadixContextMenu.SubTrigger
className={clsx({ [styles.disabled]: disabled })}
disabled={disabled}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
{children}
</RadixContextMenu.SubTrigger>
);
}
ContextMenu.Target = Target;
ContextMenu.Content = Content;
ContextMenu.Item = Item;
ContextMenu.Label = Label;
ContextMenu.Group = RadixContextMenu.Group;
ContextMenu.Submenu = Submenu;
ContextMenu.SubmenuTarget = SubmenuTarget;
ContextMenu.SubmenuContent = SubmenuContent;
ContextMenu.Divider = Divider;
ContextMenu.Arrow = RadixContextMenu.Arrow;