Files
2026-07-13 13:32:57 +08:00

323 lines
9.1 KiB
TypeScript

"use client";
import { CheckIcon } from "@heroicons/react/20/solid";
import { EllipsisVerticalIcon } from "@heroicons/react/24/solid";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { Link } from "@remix-run/react";
import * as React from "react";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import * as useShortcutKeys from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ButtonContent, type ButtonContentPropsType } from "./Buttons";
import { type RenderIcon } from "./Icon";
import { Paragraph, type ParagraphVariant } from "./Paragraph";
import { ShortcutKey } from "./ShortcutKey";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
avoidCollisions={true}
className={cn(
"z-50 min-w-max rounded border border-grid-bright bg-background-bright p-4 shadow-md outline-hidden animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
style={{
maxHeight: "var(--radix-popover-content-available-height)",
}}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
function PopoverSectionHeader({
title,
variant = "extra-small",
}: {
title: string;
variant?: ParagraphVariant;
}) {
return (
<Paragraph variant={variant} className="bg-background-hover px-2.5 py-1.5">
{title}
</Paragraph>
);
}
const PopoverMenuItem = React.forwardRef<
HTMLButtonElement | HTMLAnchorElement,
{
to?: string;
icon?: RenderIcon;
title: React.ReactNode;
isSelected?: boolean;
variant?: ButtonContentPropsType;
leadingIconClassName?: string;
className?: string;
onClick?: React.MouseEventHandler;
disabled?: boolean;
openInNewTab?: boolean;
name?: string;
value?: string;
type?: React.ComponentProps<"button">["type"];
danger?: boolean;
}
>(
(
{
to,
icon,
title,
isSelected,
variant = { variant: "small-menu-item" },
leadingIconClassName,
className,
onClick,
disabled,
openInNewTab = false,
name,
value,
type,
danger = false,
},
ref
) => {
const contentProps = {
variant: variant.variant,
LeadingIcon: icon,
leadingIconClassName: danger
? cn(leadingIconClassName, "transition-colors group-hover/button:text-error")
: leadingIconClassName,
fullWidth: true,
textAlignLeft: true,
TrailingIcon: isSelected ? CheckIcon : undefined,
className: cn(
danger
? "transition-colors group-hover/button:bg-error/10 group-hover/button:text-error [&_span]:transition-colors group-hover/button:[&_span]:text-error"
: "group-hover:bg-background-raised",
isSelected ? "bg-background-hover group-hover:bg-surface-control/50" : undefined,
className
),
} as const;
if (to) {
return (
<Link
to={to}
ref={ref as React.Ref<HTMLAnchorElement>}
className={cn("group/button focus-custom", contentProps.fullWidth ? "w-full" : "")}
onClick={onClick as any}
target={openInNewTab ? "_blank" : undefined}
rel={openInNewTab ? "noopener noreferrer" : undefined}
>
<ButtonContent {...contentProps}>{title}</ButtonContent>
</Link>
);
}
return (
<button
ref={ref as React.Ref<HTMLButtonElement>}
onClick={onClick}
disabled={disabled}
className={cn(
"group/button outline-hidden focus-custom",
contentProps.fullWidth ? "w-full" : ""
)}
name={name}
value={value}
type={type ?? "button"}
>
<ButtonContent {...contentProps}>{title}</ButtonContent>
</button>
);
}
);
PopoverMenuItem.displayName = "PopoverMenuItem";
function PopoverCustomTrigger({
isOpen,
children,
className,
...props
}: { isOpen?: boolean } & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
return (
<PopoverTrigger
{...props}
className={cn(
"group flex items-center justify-end gap-1 rounded text-text-dimmed transition focus-custom hover:bg-background-dimmed hover:text-text-bright",
className
)}
>
{children}
</PopoverTrigger>
);
}
function PopoverSideMenuTrigger({
isOpen,
children,
className,
shortcut,
hideShortcutKey = false,
...props
}: {
isOpen?: boolean;
shortcut?: useShortcutKeys.ShortcutDefinition;
hideShortcutKey?: boolean;
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
const ref = React.useRef<HTMLButtonElement>(null);
useShortcutKeys.useShortcutKeys({
shortcut: shortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
if (ref.current) {
ref.current.click();
}
},
});
return (
<PopoverTrigger
{...props}
ref={ref}
className={cn(
"flex h-[1.8rem] shrink-0 select-none items-center rounded-sm bg-transparent pl-[0.4rem] pr-2.5 text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-background-hover",
shortcut && !hideShortcutKey ? "justify-between gap-x-1.5" : "",
className
)}
>
{children}
{shortcut && !hideShortcutKey && (
<ShortcutKey className="size-4 flex-none" shortcut={shortcut} variant={"small"} />
)}
</PopoverTrigger>
);
}
const popoverArrowTriggerVariants = {
minimal: {
trigger: "text-text-dimmed hover:bg-background-raised hover:text-text-bright",
text: "group-hover:text-text-bright",
icon: "text-text-dimmed group-hover:text-text-bright",
},
primary: {
trigger:
"bg-indigo-600 border border-indigo-500 text-text-bright hover:bg-indigo-500 hover:border-indigo-400 disabled:opacity-50 disabled:pointer-events-none",
text: "text-text-bright hover:text-white",
icon: "text-text-bright",
},
secondary: {
trigger:
"bg-secondary border border-border-bright text-text-bright hover:bg-surface-control hover:border-border-brighter disabled:opacity-60 disabled:pointer-events-none",
text: "text-text-bright",
icon: "text-text-bright",
},
tertiary: {
trigger: "bg-tertiary text-text-bright hover:bg-surface-control",
text: "text-text-bright",
icon: "text-text-bright",
},
} as const;
type PopoverArrowTriggerVariant = keyof typeof popoverArrowTriggerVariants;
function PopoverArrowTrigger({
isOpen,
children,
fullWidth = false,
overflowHidden = false,
variant = "minimal",
className,
...props
}: {
isOpen?: boolean;
fullWidth?: boolean;
overflowHidden?: boolean;
variant?: PopoverArrowTriggerVariant;
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
const variantStyles = popoverArrowTriggerVariants[variant];
return (
<PopoverTrigger
{...props}
className={cn(
"group flex h-6 items-center gap-1 rounded pl-2 pr-1 transition focus-custom",
variantStyles.trigger,
fullWidth && "w-full justify-between",
className
)}
>
<Paragraph
variant="extra-small"
className={cn("flex transition", variantStyles.text, overflowHidden && "overflow-hidden")}
>
{children}
</Paragraph>
<DropdownIcon className={cn("size-4 min-w-4 transition", variantStyles.icon)} />
</PopoverTrigger>
);
}
const popoverVerticalEllipseVariants = {
minimal: {
trigger: "size-6 rounded-[3px] text-text-dimmed hover:bg-tertiary hover:text-text-bright",
icon: "size-5",
},
secondary: {
trigger:
"size-6 rounded border border-border-bright bg-secondary text-text-bright hover:bg-surface-control hover:border-border-brighter",
icon: "size-4",
},
} as const;
type PopoverVerticalEllipseVariant = keyof typeof popoverVerticalEllipseVariants;
function PopoverVerticalEllipseTrigger({
isOpen,
variant = "minimal",
className,
...props
}: {
isOpen?: boolean;
variant?: PopoverVerticalEllipseVariant;
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
const styles = popoverVerticalEllipseVariants[variant];
return (
<PopoverTrigger
{...props}
className={cn(
"group flex items-center justify-center transition focus-custom",
styles.trigger,
className
)}
>
<EllipsisVerticalIcon className={cn(styles.icon, "transition")} />
</PopoverTrigger>
);
}
export {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverCustomTrigger,
PopoverMenuItem,
PopoverSectionHeader,
PopoverSideMenuTrigger,
PopoverTrigger,
PopoverVerticalEllipseTrigger,
};
export type { PopoverArrowTriggerVariant };