mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 12:30:12 +02:00
add new context menu implementation
This commit is contained in:
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useSessionStorage as useMantineSessionStorage } from '@mantine/hooks';
|
||||
|
||||
export const useSessionStorage = useMantineSessionStorage;
|
||||
Reference in New Issue
Block a user