323 lines
9.1 KiB
TypeScript
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 };
|