组件展示
Exhibition 对话框
UI对话框组件,在PC端与Mobile端都支持,基于shadcn/ui的Dialog和Drawer组件封装
使用示例
<Exhibition>
<ExhibitionTrigger asChild>
<button className="btn">打开弹窗</button>
</ExhibitionTrigger>
<ExhibitionContent>
<ExhibitionHeader>
<ExhibitionTitle>标题</ExhibitionTitle>
<ExhibitionDescription>描述</ExhibitionDescription>
</ExhibitionHeader>
<ExhibitionBody>内容</ExhibitionBody>
<ExhibitionFooter>
<ExhibitionClose asChild>
<button className="btn">关闭</button>
</ExhibitionClose>
</ExhibitionFooter>
</ExhibitionContent>
</Exhibition>
组件源码
"use client";
import { useMemo, createContext, useContext, useState, Fragment } from "react";
import { cn } from "@components/lib/utils";
import { useMediaQuery } from "@hooks/useMediaQuery";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@components/ui/drawer";
interface BaseProps {
children: React.ReactNode;
}
interface RootExhibitionProps extends BaseProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
interface ExhibitionProps extends BaseProps {
className?: React.HTMLAttributes<HTMLDivElement>["className"];
asChild?: true;
}
// 定义 Context 类型
interface ExhibitionContextType {
isDesktop: boolean;
Comp: typeof Dialog | typeof Drawer;
Trigger: typeof DialogTrigger | typeof DrawerTrigger;
Close: typeof DialogClose | typeof DrawerClose;
Content: typeof DialogContent | typeof DrawerContent;
Header: typeof DialogHeader | typeof DrawerHeader;
Title: typeof DialogTitle | typeof DrawerTitle;
Footer: typeof DialogFooter | typeof DrawerFooter;
Description: typeof DialogDescription | typeof DrawerDescription;
drawerProps: Record<string, any>;
}
// 创建 Context
const ExhibitionContext = createContext<ExhibitionContextType | null>(null);
// 自定义 Hook
function useExhibitionContext() {
const context = useContext(ExhibitionContext);
if (!context) {
throw new Error("Exhibition components must be used within Exhibition");
}
return context;
}
// 主组件
export function Exhibition({ children, ...props }: RootExhibitionProps) {
const isDesktop = useMediaQuery("desktop", {
defaultValue: true, // 在SSR时默认为桌面端
initializeWithValue: false, // 避免SSR时的水合问题
});
const Comp = isDesktop ? Dialog : Drawer;
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
const Close = isDesktop ? DialogClose : DrawerClose;
const Content = isDesktop ? DialogContent : DrawerContent;
const Header = isDesktop ? DialogHeader : DrawerHeader;
const Title = isDesktop ? DialogTitle : DrawerTitle;
const Footer = isDesktop ? DialogFooter : DrawerFooter;
const Description = isDesktop ? DialogDescription : DrawerDescription;
const contextValue = useMemo(
() => ({
isDesktop,
Comp,
Trigger,
Close,
Content,
Header,
Title,
Footer,
Description,
drawerProps: !isDesktop ? { autoFocus: true } : {},
}),
[
isDesktop,
Comp,
Trigger,
Close,
Content,
Header,
Title,
Footer,
Description,
],
);
return (
<ExhibitionContext value={contextValue}>
<Comp {...props} {...contextValue.drawerProps}>
{children}
</Comp>
</ExhibitionContext>
);
}
export function ExhibitionTrigger({
children,
className,
...props
}: ExhibitionProps) {
const { Trigger } = useExhibitionContext();
return (
<Trigger className={cn(className)} {...props}>
{children}
</Trigger>
);
}
export function ExhibitionClose({
children,
className,
...props
}: ExhibitionProps) {
const { Close } = useExhibitionContext();
return (
<Close className={cn(className)} {...props}>
{children}
</Close>
);
}
export function ExhibitionContent({
children,
className,
...props
}: ExhibitionProps) {
const { Content } = useExhibitionContext();
return (
<Content className={cn(className)} {...props}>
{children}
</Content>
);
}
export function ExhibitionHeader({
children,
className,
...props
}: ExhibitionProps) {
const { Header } = useExhibitionContext();
return (
<Header className={cn(className)} {...props}>
{children}
</Header>
);
}
export function ExhibitionTitle({
children,
className,
...props
}: ExhibitionProps) {
const { Title } = useExhibitionContext();
return (
<Title className={cn(className)} {...props}>
{children}
</Title>
);
}
export function ExhibitionDescription({
children,
className,
...props
}: ExhibitionProps) {
const { Description } = useExhibitionContext();
return (
<Description className={cn(className)} {...props}>
{children}
</Description>
);
}
export function ExhibitionBody({
children,
className,
...props
}: ExhibitionProps) {
return (
<div className={cn("px-4 md:px-0", className)} {...props}>
{children}
</div>
);
}
export function ExhibitionFooter({
children,
className,
...props
}: ExhibitionProps) {
const { Footer } = useExhibitionContext();
return (
<Footer className={cn(className)} {...props}>
{children}
</Footer>
);
}
export function ExhibitionDemo() {
return (
<Exhibition>
<ExhibitionTrigger asChild>
<button
className="btn border-accent border rounded-md p-2"
type="button"
>
组件打开弹窗
</button>
</ExhibitionTrigger>
<ExhibitionContent>
<ExhibitionHeader>
<ExhibitionTitle>组件标题</ExhibitionTitle>
<ExhibitionDescription>组件描述</ExhibitionDescription>
</ExhibitionHeader>
<ExhibitionBody>
<p>
This component is built using shadcn/ui's dialog and drawer
component, which is built on top of Vaul.
</p>
</ExhibitionBody>
<ExhibitionFooter>
<ExhibitionClose asChild className=" justify-around">
<div className="flex gap-2 w-full">
<button type="button" className="btn btn-outline">
取消
</button>
<button type="button" className="btn btn-outline">
确认
</button>
</div>
</ExhibitionClose>
</ExhibitionFooter>
</ExhibitionContent>
</Exhibition>
);
}
export function ExhibitionStateDemo() {
const [open, setOpen] = useState(false);
return (
<div>
<button
className="btn border-accent border rounded-md p-2"
type="button"
onClick={() => setOpen(true)}
>
状态打开弹窗
</button>
<Exhibition open={open} onOpenChange={setOpen}>
<ExhibitionContent>
<ExhibitionHeader>
<ExhibitionTitle>组件标题</ExhibitionTitle>
<ExhibitionDescription>组件描述</ExhibitionDescription>
</ExhibitionHeader>
<ExhibitionBody>
<p>
This component is built using shadcn/ui's dialog and drawer
component, which is built on top of Vaul.
</p>
</ExhibitionBody>
<ExhibitionFooter>
<ExhibitionClose asChild className="">
<button type="button" className="btn btn-outline">
取消
</button>
</ExhibitionClose>
</ExhibitionFooter>
</ExhibitionContent>
</Exhibition>
</div>
);
}
Extension Button
UI增强版按钮组件,基于shadcn/ui的Button组件封装,支持图标、加载状态、特效动画、复制功能等
使用示例
// 基础用法
<Button>按钮</Button>
// 带图标
<Button icon={ArrowRight} iconPlacement="right">
带图标按钮
</Button>
// 加载状态
<Button loading loadingIconPlacement="left">
加载中
</Button>
// 不同变体
<Button variant="destructive">危险按钮</Button>
<Button variant="outline">轮廓按钮</Button>
<Button variant="ghost">幽灵按钮</Button>
// 特效动画
<Button effect="shineHover">闪烁效果</Button>
<Button effect="gooeyRight">粘性效果</Button>
<Button effect="gradientSlideShow">渐变按钮</Button>
// 复制功能
<Button copyText="Hello World" icon={CopyIcon} iconPlacement="left">
复制文本
</Button>
// 自定义复制成功提示
<Button
copyText="Hello World"
icon={CopyIcon}
iconPlacement="left"
successText="已复制!"
timeout={5000}
>
复制文本
</Button>
// 带复制回调
<Button
copyText="Hello World"
icon={CopyIcon}
iconPlacement="left"
onCopy={(text) => console.log('Copied:', text)}
>
复制文本
</Button>
组件源码
"use client";
import * as React from "react";
import { Slot, Slottable } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { Spinner } from "@components/ui/Spinner";
import { cn } from "@components/lib/utils";
import useCopy from "@hooks/useCopy";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 box-border",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90 border border-accent rounded-md",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4",
},
effect: {
expandIcon: "group gap-0 relative",
ringHover:
"transition-all duration-300 hover:ring-2 hover:ring-primary/90 hover:ring-offset-2",
shine:
"relative overflow-hidden before:absolute before:inset-0 before:rounded-[inherit] before:bg-[linear-gradient(45deg,transparent_40%,rgba(255,255,255,0.3)_50%,transparent_60%)] before:bg-[length:250%_250%] before:bg-no-repeat before:bg-[position:200%_0] before:[animation:shine_3s_ease-out_infinite]",
shineHover:
"relative overflow-hidden before:absolute before:inset-0 before:rounded-[inherit] before:bg-[linear-gradient(45deg,transparent_40%,rgba(255,255,255,0.3)_50%,transparent_60%)] before:bg-[length:250%_250%] before:bg-no-repeat before:bg-[position:200%_0] before:transition-[background-position] before:duration-[1200ms] hover:before:bg-[position:-200%_0]",
gooeyRight:
"relative z-0 overflow-hidden transition-all duration-500 before:absolute before:inset-0 before:z-[-1] before:translate-x-[150%] before:translate-y-[150%] before:scale-[2.5] before:rounded-[100%] before:bg-white/10 before:transition-transform before:duration-1000 hover:before:translate-x-[0%] hover:before:translate-y-[0%]",
gooeyLeft:
"relative z-0 overflow-hidden transition-all duration-500 after:absolute after:inset-0 after:z-[-1] after:translate-x-[-150%] after:translate-y-[150%] after:scale-[2.5] after:rounded-[100%] after:bg-white/10 after:transition-transform after:duration-1000 hover:after:translate-x-[0%] hover:after:translate-y-[0%]",
underline:
"relative !no-underline after:absolute after:left-0 after:right-0 after:content-[''] after:bg-accent after:bottom-1 after:h-[1px] after:origin-bottom-left after:scale-x-100 hover:after:origin-bottom-right hover:after:scale-x-0 after:transition-transform after:ease-in-out after:duration-300",
hoverUnderline:
"relative !no-underline after:absolute after:left-0 after:right-0 after:content-[''] after:bg-accent after:bottom-1 after:h-[1px] after:origin-bottom-right after:scale-x-0 hover:after:origin-bottom-left hover:after:scale-x-100 after:transition-transform after:ease-in-out after:duration-300",
gradientSlideShow:
"bg-[size:400%] bg-[linear-gradient(-45deg,var(--gradient-lime),var(--gradient-ocean),var(--gradient-wine),var(--gradient-rust))] animate-gradient-flow",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
size: "default",
variant: "default",
},
},
);
type CopyProps = {
copyText?: string;
onCopy?: (text: string) => void;
successText?: React.ReactNode;
timeout?: number;
};
type CopyRefProps = {
copyText?: never;
onCopy?: never;
successText?: never;
timeout?: never;
};
export type CopyButtonProps = CopyProps | CopyRefProps;
type IconProps = {
icon?: React.ElementType;
iconPlacement: "left" | "right";
};
type IconRefProps = {
icon?: never;
iconPlacement?: undefined;
};
type LoadingProps = {
loading?: boolean;
loadingText?: string;
hideIconOnLoading?: boolean;
loadingIconPlacement?: "left" | "right";
};
export type ButtonIconProps = IconProps | IconRefProps;
export type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> &
ButtonIconProps &
CopyButtonProps &
LoadingProps & {
asChild?: boolean;
};
function Button({
className,
variant,
effect,
size,
icon: Icon,
iconPlacement,
loading,
loadingText = "Loading",
children,
hideIconOnLoading = false,
loadingIconPlacement = "right",
asChild = false,
copyText,
onCopy,
successText = "Copied!",
timeout = 3000,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
const timerRef = React.useRef<NodeJS.Timeout | null>(null);
const { copied, copyHandle, resetHandle } = useCopy();
React.useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
const handleCopy = async () => {
if (!copyText) {
return;
}
try {
await copyHandle(copyText);
onCopy?.(copyText);
// 设置定时器自动重置状态
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
resetHandle();
}, timeout);
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (copyText) {
handleCopy();
}
props.onClick?.(e);
};
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className, effect }))}
disabled={loading}
onClick={handleClick}
{...props}
>
{/* loading 左侧图标 */}
{loading && loadingIconPlacement === "left" && <Spinner size="sm" />}
{/* icon */}
{Icon &&
iconPlacement === "left" &&
!(hideIconOnLoading && loading) &&
(effect === "expandIcon" ? (
<div className="w-0 translate-x-[-100%] pr-0 opacity-0 transition-all duration-200 group-hover:w-5 group-hover:translate-x-0 group-hover:pr-2 group-hover:opacity-100 overflow-hidden">
<Icon />
</div>
) : (
<Icon />
))}
<Slottable>
{loading ? loadingText : copied ? successText : children}
</Slottable>
{/* loading 右侧图标 */}
{loading && loadingIconPlacement === "right" && <Spinner size="sm" />}
{Icon &&
iconPlacement === "right" &&
!(hideIconOnLoading && loading) &&
(effect === "expandIcon" ? (
<div className="w-0 translate-x-[100%] pl-0 opacity-0 transition-all duration-200 group-hover:w-5 group-hover:translate-x-0 group-hover:pl-2 group-hover:opacity-100">
<Icon />
) : (
<Icon />
))}
</Comp>
);
}
export { Button, buttonVariants };