@zmzai/theme 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +76 -0
- package/package.json +91 -0
- package/src/brand/index.ts +3 -0
- package/src/brand/logo/Logo.tsx +46 -0
- package/src/brand/logo/favicon.png +0 -0
- package/src/brand/logo/index.ts +2 -0
- package/src/brand/logo/zmzai-logo.png +0 -0
- package/src/brand/wordmark/Wordmark.tsx +48 -0
- package/src/brand/wordmark/index.ts +2 -0
- package/src/components/aceternity-sidebar/AceternitySidebar.tsx +133 -0
- package/src/components/aceternity-sidebar/index.ts +2 -0
- package/src/components/animated-tabs/AnimatedTabs.tsx +60 -0
- package/src/components/animated-tabs/index.ts +2 -0
- package/src/components/animated-tooltip/AnimatedTooltip.tsx +93 -0
- package/src/components/animated-tooltip/index.ts +2 -0
- package/src/components/avatar/Avatar.tsx +59 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/badge/Badge.tsx +45 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/bento-grid/BentoGrid.tsx +85 -0
- package/src/components/bento-grid/index.ts +2 -0
- package/src/components/button/Button.tsx +66 -0
- package/src/components/button/button.styles.ts +41 -0
- package/src/components/button/index.ts +4 -0
- package/src/components/card/Card.tsx +87 -0
- package/src/components/card/card.styles.ts +35 -0
- package/src/components/card/index.ts +4 -0
- package/src/components/card-3d/Card3D.tsx +84 -0
- package/src/components/card-3d/index.ts +2 -0
- package/src/components/card-hover-effect/CardHoverEffect.tsx +83 -0
- package/src/components/card-hover-effect/index.ts +2 -0
- package/src/components/card-spotlight/CardSpotlight.tsx +63 -0
- package/src/components/card-spotlight/index.ts +2 -0
- package/src/components/carousel/Carousel.tsx +79 -0
- package/src/components/carousel/index.ts +2 -0
- package/src/components/code-block/CodeBlock.tsx +153 -0
- package/src/components/code-block/index.ts +2 -0
- package/src/components/comet-card/CometCard.tsx +106 -0
- package/src/components/comet-card/index.ts +2 -0
- package/src/components/compare/Compare.tsx +128 -0
- package/src/components/compare/index.ts +2 -0
- package/src/components/dialog/Dialog.tsx +79 -0
- package/src/components/dialog/index.ts +10 -0
- package/src/components/dropdown-menu/DropdownMenu.tsx +76 -0
- package/src/components/dropdown-menu/index.ts +8 -0
- package/src/components/empty-state/EmptyState.tsx +78 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/encrypt-text/EncryptText.tsx +102 -0
- package/src/components/encrypt-text/index.ts +2 -0
- package/src/components/evervault-card/EvervaultCard.tsx +114 -0
- package/src/components/evervault-card/index.ts +2 -0
- package/src/components/expandable-card/ExpandableCard.tsx +91 -0
- package/src/components/expandable-card/index.ts +2 -0
- package/src/components/file-upload/FileUpload.tsx +171 -0
- package/src/components/file-upload/index.ts +2 -0
- package/src/components/flip-words/FlipWords.tsx +70 -0
- package/src/components/flip-words/index.ts +2 -0
- package/src/components/floating-navbar/FloatingNavbar.tsx +82 -0
- package/src/components/floating-navbar/index.ts +2 -0
- package/src/components/focus-cards/FocusCards.tsx +93 -0
- package/src/components/focus-cards/index.ts +2 -0
- package/src/components/glare-card/GlareCard.tsx +66 -0
- package/src/components/glare-card/index.ts +2 -0
- package/src/components/glowing-effect/GlowingEffect.tsx +68 -0
- package/src/components/glowing-effect/index.ts +2 -0
- package/src/components/hover-border-gradient/HoverBorderGradient.tsx +80 -0
- package/src/components/hover-border-gradient/index.ts +2 -0
- package/src/components/index.ts +71 -0
- package/src/components/infinite-moving-cards/InfiniteMovingCards.tsx +84 -0
- package/src/components/infinite-moving-cards/index.ts +2 -0
- package/src/components/input/Input.tsx +35 -0
- package/src/components/input/index.ts +4 -0
- package/src/components/input/input.styles.ts +34 -0
- package/src/components/keyboard/Keyboard.tsx +38 -0
- package/src/components/keyboard/index.ts +2 -0
- package/src/components/layout-grid/LayoutGrid.tsx +121 -0
- package/src/components/layout-grid/index.ts +2 -0
- package/src/components/link-preview/LinkPreview.tsx +121 -0
- package/src/components/link-preview/index.ts +2 -0
- package/src/components/loader/Loader.tsx +130 -0
- package/src/components/loader/index.ts +2 -0
- package/src/components/magnetic-button/MagneticButton.tsx +79 -0
- package/src/components/magnetic-button/index.ts +2 -0
- package/src/components/moving-border/MovingBorder.tsx +72 -0
- package/src/components/moving-border/index.ts +2 -0
- package/src/components/multi-step-loader/MultiStepLoader.tsx +95 -0
- package/src/components/multi-step-loader/index.ts +2 -0
- package/src/components/notch/Notch.tsx +47 -0
- package/src/components/notch/index.ts +2 -0
- package/src/components/select/Select.tsx +100 -0
- package/src/components/select/index.ts +10 -0
- package/src/components/skeleton/Skeleton.tsx +34 -0
- package/src/components/skeleton/index.ts +2 -0
- package/src/components/stateful-button/StatefulButton.tsx +115 -0
- package/src/components/stateful-button/index.ts +2 -0
- package/src/components/sticky-banner/StickyBanner.tsx +74 -0
- package/src/components/sticky-banner/index.ts +2 -0
- package/src/components/sticky-scroll-reveal/StickyScrollReveal.tsx +141 -0
- package/src/components/sticky-scroll-reveal/index.ts +2 -0
- package/src/components/terminal/Terminal.tsx +55 -0
- package/src/components/terminal/index.ts +2 -0
- package/src/components/text-generate/TextGenerate.tsx +59 -0
- package/src/components/text-generate/index.ts +2 -0
- package/src/components/text-hover-effect/TextHoverEffect.tsx +90 -0
- package/src/components/text-hover-effect/index.ts +2 -0
- package/src/components/textarea/Textarea.tsx +39 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/timeline/Timeline.tsx +106 -0
- package/src/components/timeline/index.ts +3 -0
- package/src/components/tooltip/Tooltip.tsx +51 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip-card/TooltipCard.tsx +130 -0
- package/src/components/tooltip-card/index.ts +2 -0
- package/src/components/tracing-beam/TracingBeam.tsx +110 -0
- package/src/components/tracing-beam/index.ts +2 -0
- package/src/components/typewriter/Typewriter.tsx +69 -0
- package/src/components/typewriter/index.ts +2 -0
- package/src/components/vanish-input/VanishInput.tsx +170 -0
- package/src/components/vanish-input/index.ts +2 -0
- package/src/components/wobble-card/WobbleCard.tsx +77 -0
- package/src/components/wobble-card/index.ts +2 -0
- package/src/index.ts +21 -0
- package/src/tokens/fonts.css +42 -0
- package/src/tokens/theme.css +120 -0
- package/src/utils/cn.ts +13 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/motion.ts +47 -0
- package/src/vite-env.d.ts +6 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, type MouseEvent, type ReactNode } from "react";
|
|
4
|
+
import {
|
|
5
|
+
motion,
|
|
6
|
+
useMotionValue,
|
|
7
|
+
useSpring,
|
|
8
|
+
type HTMLMotionProps,
|
|
9
|
+
} from "framer-motion";
|
|
10
|
+
import { cn } from "../../utils/cn";
|
|
11
|
+
|
|
12
|
+
export interface MagneticButtonProps
|
|
13
|
+
extends Omit<HTMLMotionProps<"button">, "ref" | "children"> {
|
|
14
|
+
children?: ReactNode;
|
|
15
|
+
/** How strongly the button drifts toward the cursor (default 0.3 = 30%) */
|
|
16
|
+
magnetism?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* MagneticButton — a button that subtly drifts toward the cursor.
|
|
21
|
+
*
|
|
22
|
+
* Uses framer-motion `useMotionValue` + `useSpring` to translate the
|
|
23
|
+
* button toward the mouse position by a fraction of the distance, then
|
|
24
|
+
* springs back to center on mouse leave. Pure micro-interaction, no
|
|
25
|
+
* layout shift.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* <MagneticButton className="rounded-full bg-ink px-6 py-3 text-white">
|
|
29
|
+
* 立即开始
|
|
30
|
+
* </MagneticButton>
|
|
31
|
+
*/
|
|
32
|
+
export function MagneticButton({
|
|
33
|
+
children,
|
|
34
|
+
className,
|
|
35
|
+
magnetism = 0.3,
|
|
36
|
+
onMouseMove,
|
|
37
|
+
onMouseLeave,
|
|
38
|
+
...props
|
|
39
|
+
}: MagneticButtonProps) {
|
|
40
|
+
const ref = useRef<HTMLButtonElement>(null);
|
|
41
|
+
const x = useMotionValue(0);
|
|
42
|
+
const y = useMotionValue(0);
|
|
43
|
+
|
|
44
|
+
const springX = useSpring(x, { stiffness: 200, damping: 15 });
|
|
45
|
+
const springY = useSpring(y, { stiffness: 200, damping: 15 });
|
|
46
|
+
|
|
47
|
+
function handleMouseMove(e: MouseEvent<HTMLButtonElement>) {
|
|
48
|
+
const el = ref.current;
|
|
49
|
+
if (!el) return;
|
|
50
|
+
const rect = el.getBoundingClientRect();
|
|
51
|
+
const relX = e.clientX - (rect.left + rect.width / 2);
|
|
52
|
+
const relY = e.clientY - (rect.top + rect.height / 2);
|
|
53
|
+
x.set(relX * magnetism);
|
|
54
|
+
y.set(relY * magnetism);
|
|
55
|
+
onMouseMove?.(e);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function handleMouseLeave(e: MouseEvent<HTMLButtonElement>) {
|
|
59
|
+
x.set(0);
|
|
60
|
+
y.set(0);
|
|
61
|
+
onMouseLeave?.(e);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<motion.button
|
|
66
|
+
ref={ref}
|
|
67
|
+
onMouseMove={handleMouseMove}
|
|
68
|
+
onMouseLeave={handleMouseLeave}
|
|
69
|
+
style={{ x: springX, y: springY }}
|
|
70
|
+
className={cn(
|
|
71
|
+
"relative inline-flex cursor-pointer items-center justify-center rounded-full bg-ink px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-ink/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg disabled:cursor-not-allowed disabled:opacity-50",
|
|
72
|
+
className
|
|
73
|
+
)}
|
|
74
|
+
{...props}
|
|
75
|
+
>
|
|
76
|
+
{children}
|
|
77
|
+
</motion.button>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ReactNode } from "react";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
|
|
6
|
+
export interface MovingBorderProps {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
/** Border rotation duration in seconds (default 5) */
|
|
10
|
+
duration?: number;
|
|
11
|
+
/** Border color (default accent blue) */
|
|
12
|
+
borderColor?: string;
|
|
13
|
+
/** Inner background color (default bg) */
|
|
14
|
+
backgroundColor?: string;
|
|
15
|
+
/** Border radius (default rounded-xl = 12px) */
|
|
16
|
+
borderRadius?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* MovingBorder — container with rotating conic-gradient border beam.
|
|
21
|
+
*
|
|
22
|
+
* Uses CSS `@property --angle` for a smooth rotating light beam around
|
|
23
|
+
* the border. Based on Aceternity's Moving Border, adapted to zmzai's
|
|
24
|
+
* blue accent. Only the border moves — inner content stays static.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* <MovingBorder duration={5}>
|
|
28
|
+
* <input placeholder="聚焦看边框光束…" />
|
|
29
|
+
* </MovingBorder>
|
|
30
|
+
*/
|
|
31
|
+
export function MovingBorder({
|
|
32
|
+
children,
|
|
33
|
+
className,
|
|
34
|
+
duration = 5,
|
|
35
|
+
borderColor = "var(--color-accent)",
|
|
36
|
+
backgroundColor = "var(--color-bg)",
|
|
37
|
+
borderRadius = "12px",
|
|
38
|
+
}: MovingBorderProps) {
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
{/* @property must be in global scope — inject once */}
|
|
42
|
+
<style>{`
|
|
43
|
+
@property --mb-angle {
|
|
44
|
+
syntax: '<angle>';
|
|
45
|
+
initial-value: 0deg;
|
|
46
|
+
inherits: false;
|
|
47
|
+
}
|
|
48
|
+
@keyframes mb-rotate { to { --mb-angle: 360deg; } }
|
|
49
|
+
`}</style>
|
|
50
|
+
|
|
51
|
+
<div
|
|
52
|
+
className={cn("relative", className)}
|
|
53
|
+
style={{
|
|
54
|
+
borderRadius,
|
|
55
|
+
padding: "1px",
|
|
56
|
+
background: `conic-gradient(from var(--mb-angle), transparent 0%, transparent 60%, ${borderColor} 80%, transparent 95%)`,
|
|
57
|
+
animation: `mb-rotate ${duration}s linear infinite`,
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<div
|
|
61
|
+
className="relative h-full w-full"
|
|
62
|
+
style={{
|
|
63
|
+
borderRadius: `calc(${borderRadius} - 1px)`,
|
|
64
|
+
background: backgroundColor,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
|
|
6
|
+
export interface MultiStepLoaderStep {
|
|
7
|
+
/** Display text for this step */
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MultiStepLoaderProps {
|
|
12
|
+
/** Steps to display. Each shows sequentially. */
|
|
13
|
+
steps: MultiStepLoaderStep[];
|
|
14
|
+
/** Currently active step index (0-based). Steps before are completed. */
|
|
15
|
+
currentIndex: number;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* MultiStepLoader — sequential step loader for long-running tasks.
|
|
21
|
+
*
|
|
22
|
+
* Each step has 3 states: completed (checkmark), active (spinner), pending (dot).
|
|
23
|
+
* Perfect for Agent task execution feedback.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const steps = [{ text: "分析需求" }, { text: "生成代码" }, { text: "运行测试" }];
|
|
27
|
+
* <MultiStepLoader steps={steps} currentIndex={1} />
|
|
28
|
+
*/
|
|
29
|
+
export function MultiStepLoader({
|
|
30
|
+
steps,
|
|
31
|
+
currentIndex,
|
|
32
|
+
className,
|
|
33
|
+
}: MultiStepLoaderProps) {
|
|
34
|
+
return (
|
|
35
|
+
<div className={cn("flex flex-col gap-3", className)}>
|
|
36
|
+
{steps.map((step, index) => {
|
|
37
|
+
const isCompleted = index < currentIndex;
|
|
38
|
+
const isActive = index === currentIndex;
|
|
39
|
+
const isPending = index > currentIndex;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div key={index} className="flex items-center gap-3">
|
|
43
|
+
{/* Status indicator */}
|
|
44
|
+
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
|
|
45
|
+
{isCompleted && (
|
|
46
|
+
<motion.div
|
|
47
|
+
initial={{ scale: 0 }}
|
|
48
|
+
animate={{ scale: 1 }}
|
|
49
|
+
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
|
50
|
+
className="flex h-5 w-5 items-center justify-center rounded-full bg-ink"
|
|
51
|
+
>
|
|
52
|
+
<svg className="h-3 w-3 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
|
53
|
+
<polyline points="20 6 9 17 4 12" />
|
|
54
|
+
</svg>
|
|
55
|
+
</motion.div>
|
|
56
|
+
)}
|
|
57
|
+
{isActive && (
|
|
58
|
+
<svg className="h-5 w-5 animate-spin text-accent" viewBox="0 0 24 24" fill="none">
|
|
59
|
+
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
|
60
|
+
<path className="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
61
|
+
</svg>
|
|
62
|
+
)}
|
|
63
|
+
{isPending && (
|
|
64
|
+
<div className="h-2 w-2 rounded-full border border-line" />
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Text */}
|
|
69
|
+
<AnimatePresence mode="wait">
|
|
70
|
+
<motion.span
|
|
71
|
+
key={`${index}-${isCompleted}-${isActive}`}
|
|
72
|
+
initial={{ opacity: 0 }}
|
|
73
|
+
animate={{ opacity: 1 }}
|
|
74
|
+
transition={{ duration: 0.2 }}
|
|
75
|
+
className={cn(
|
|
76
|
+
"text-sm transition-colors",
|
|
77
|
+
isCompleted && "text-ink-3 line-through",
|
|
78
|
+
isActive && "font-medium text-ink",
|
|
79
|
+
isPending && "text-ink-3"
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
{step.text}
|
|
83
|
+
</motion.span>
|
|
84
|
+
</AnimatePresence>
|
|
85
|
+
|
|
86
|
+
{/* Step number */}
|
|
87
|
+
<span className="ml-auto font-mono text-xs text-ink-3">
|
|
88
|
+
{String(index + 1).padStart(2, "0")}
|
|
89
|
+
</span>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ReactNode } from "react";
|
|
4
|
+
import { AnimatePresence, motion, type HTMLMotionProps } from "framer-motion";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
|
|
7
|
+
export interface NotchProps extends Omit<HTMLMotionProps<"div">, "ref"> {
|
|
8
|
+
/** Notification content */
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
/** Whether the notch is visible */
|
|
11
|
+
visible?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Notch — a floating notification bar pinned to the top center.
|
|
16
|
+
*
|
|
17
|
+
* Inspired by the macOS Dynamic Island: a pill that slides down from the
|
|
18
|
+
* top of the viewport when `visible` becomes true and slides up on hide.
|
|
19
|
+
* Rendered in a fixed overlay so it floats above page content.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* const [open, setOpen] = useState(true)
|
|
23
|
+
* <Notch visible={open} onClose={() => setOpen(false)}>新消息</Notch>
|
|
24
|
+
*/
|
|
25
|
+
export function Notch({ children, visible = false, className, ...props }: NotchProps) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="pointer-events-none fixed inset-x-0 top-0 z-50 flex justify-center">
|
|
28
|
+
<AnimatePresence>
|
|
29
|
+
{visible && (
|
|
30
|
+
<motion.div
|
|
31
|
+
initial={{ y: -60, opacity: 0 }}
|
|
32
|
+
animate={{ y: 24, opacity: 1 }}
|
|
33
|
+
exit={{ y: -60, opacity: 0 }}
|
|
34
|
+
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
|
35
|
+
className={cn(
|
|
36
|
+
"pointer-events-auto flex items-center gap-2 rounded-full border border-line bg-ink px-4 py-2 text-sm text-white shadow-lg",
|
|
37
|
+
className
|
|
38
|
+
)}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
{children}
|
|
42
|
+
</motion.div>
|
|
43
|
+
)}
|
|
44
|
+
</AnimatePresence>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ReactNode } from "react";
|
|
4
|
+
import * as SelectPrimitive from "@radix-ui/react-select";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
|
|
7
|
+
export const Select = SelectPrimitive.Root;
|
|
8
|
+
export const SelectGroup = SelectPrimitive.Group;
|
|
9
|
+
export const SelectValue = SelectPrimitive.Value;
|
|
10
|
+
|
|
11
|
+
export function SelectTrigger({
|
|
12
|
+
children,
|
|
13
|
+
className,
|
|
14
|
+
}: {
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
className?: string;
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<SelectPrimitive.Trigger
|
|
20
|
+
className={cn(
|
|
21
|
+
"inline-flex h-9 items-center justify-between gap-2 rounded-lg border border-line bg-bg px-3 py-2 text-sm font-medium text-ink outline-none transition-colors",
|
|
22
|
+
"hover:border-ink/40 focus:border-ink data-[placeholder]:text-ink-3",
|
|
23
|
+
className
|
|
24
|
+
)}
|
|
25
|
+
>
|
|
26
|
+
{children}
|
|
27
|
+
<SelectPrimitive.Icon asChild>
|
|
28
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
|
29
|
+
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
30
|
+
</svg>
|
|
31
|
+
</SelectPrimitive.Icon>
|
|
32
|
+
</SelectPrimitive.Trigger>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function SelectContent({
|
|
37
|
+
children,
|
|
38
|
+
className,
|
|
39
|
+
position = "popper",
|
|
40
|
+
}: {
|
|
41
|
+
children: ReactNode;
|
|
42
|
+
className?: string;
|
|
43
|
+
position?: "popper" | "item-aligned";
|
|
44
|
+
}) {
|
|
45
|
+
return (
|
|
46
|
+
<SelectPrimitive.Portal>
|
|
47
|
+
<SelectPrimitive.Content
|
|
48
|
+
position={position}
|
|
49
|
+
sideOffset={4}
|
|
50
|
+
className={cn(
|
|
51
|
+
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-line bg-bg p-1 shadow-lg",
|
|
52
|
+
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
53
|
+
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
|
54
|
+
className
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<SelectPrimitive.Viewport className="w-full">{children}</SelectPrimitive.Viewport>
|
|
58
|
+
</SelectPrimitive.Content>
|
|
59
|
+
</SelectPrimitive.Portal>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function SelectItem({
|
|
64
|
+
children,
|
|
65
|
+
value,
|
|
66
|
+
className,
|
|
67
|
+
}: {
|
|
68
|
+
children: ReactNode;
|
|
69
|
+
value: string;
|
|
70
|
+
className?: string;
|
|
71
|
+
}) {
|
|
72
|
+
return (
|
|
73
|
+
<SelectPrimitive.Item
|
|
74
|
+
value={value}
|
|
75
|
+
className={cn(
|
|
76
|
+
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-7 pr-2 text-sm outline-none transition-colors",
|
|
77
|
+
"focus:bg-surface-2 focus:text-ink data-[state=checked]:font-semibold data-[highlighted]:bg-surface-2",
|
|
78
|
+
"data-[state=unchecked]:text-ink-2",
|
|
79
|
+
className
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
<SelectPrimitive.ItemIndicator className="absolute left-2">
|
|
83
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
84
|
+
<path d="M3 7L6 10L11 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
85
|
+
</svg>
|
|
86
|
+
</SelectPrimitive.ItemIndicator>
|
|
87
|
+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
88
|
+
</SelectPrimitive.Item>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function SelectLabel({ children }: { children: ReactNode }) {
|
|
93
|
+
return (
|
|
94
|
+
<div className="px-2 py-1.5 text-xs font-semibold text-ink-3">{children}</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function SelectSeparator() {
|
|
99
|
+
return <div className="my-1 h-px bg-line" />;
|
|
100
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type HTMLAttributes } from "react";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
|
|
6
|
+
export interface SkeletonProps extends HTMLAttributes<HTMLDivElement> {}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Skeleton — a shimmering placeholder block.
|
|
10
|
+
*
|
|
11
|
+
* Renders a neutral block sized by `className` (width/height/radius) with
|
|
12
|
+
* a subtle pulse animation between `bg-surface-2` and `bg-surface`.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* <Skeleton className="h-4 w-48 rounded-md" />
|
|
16
|
+
* <Skeleton className="h-32 w-full rounded-xl" />
|
|
17
|
+
*/
|
|
18
|
+
export const Skeleton = forwardRef<HTMLDivElement, SkeletonProps>(
|
|
19
|
+
({ className, ...props }, ref) => {
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
ref={ref}
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
className={cn(
|
|
25
|
+
"animate-pulse rounded-md bg-surface-2",
|
|
26
|
+
className
|
|
27
|
+
)}
|
|
28
|
+
{...props}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
Skeleton.displayName = "Skeleton";
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
import { buttonVariants, type ButtonVariant, type ButtonSize } from "../button/button.styles";
|
|
7
|
+
|
|
8
|
+
export interface StatefulButtonProps {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
/** Async function called on click. Button shows loading then success. */
|
|
11
|
+
onClick?: () => Promise<void> | void;
|
|
12
|
+
variant?: ButtonVariant;
|
|
13
|
+
size?: ButtonSize;
|
|
14
|
+
className?: string;
|
|
15
|
+
/** Success message shown briefly after completion (default "完成") */
|
|
16
|
+
successText?: string;
|
|
17
|
+
/** Duration of success state before resetting (default 1500ms) */
|
|
18
|
+
successDuration?: number;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type State = "idle" | "loading" | "success";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* StatefulButton — auto-manages idle → loading → success lifecycle.
|
|
26
|
+
*
|
|
27
|
+
* Wrap any async action: the button shows a spinner during execution,
|
|
28
|
+
* then briefly displays a checkmark + success text before resetting.
|
|
29
|
+
* Built on zmzai Button variants + framer-motion state transitions.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* <StatefulButton onClick={async () => { await deploy(); }}>
|
|
33
|
+
* 部署
|
|
34
|
+
* </StatefulButton>
|
|
35
|
+
*/
|
|
36
|
+
export function StatefulButton({
|
|
37
|
+
children,
|
|
38
|
+
onClick,
|
|
39
|
+
variant = "primary",
|
|
40
|
+
size = "md",
|
|
41
|
+
className,
|
|
42
|
+
successText = "完成",
|
|
43
|
+
successDuration = 1500,
|
|
44
|
+
disabled,
|
|
45
|
+
}: StatefulButtonProps) {
|
|
46
|
+
const [state, setState] = useState<State>("idle");
|
|
47
|
+
|
|
48
|
+
const handleClick = async () => {
|
|
49
|
+
if (state !== "idle" || disabled) return;
|
|
50
|
+
setState("loading");
|
|
51
|
+
try {
|
|
52
|
+
await onClick?.();
|
|
53
|
+
setState("success");
|
|
54
|
+
setTimeout(() => setState("idle"), successDuration);
|
|
55
|
+
} catch {
|
|
56
|
+
setState("idle");
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<motion.button
|
|
62
|
+
onClick={handleClick}
|
|
63
|
+
disabled={disabled || state === "loading"}
|
|
64
|
+
whileHover={disabled || state !== "idle" ? undefined : { scale: 1.03 }}
|
|
65
|
+
whileTap={disabled || state !== "idle" ? undefined : { scale: 0.98 }}
|
|
66
|
+
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
|
67
|
+
className={cn(buttonVariants({ variant, size }), "overflow-hidden", className)}
|
|
68
|
+
>
|
|
69
|
+
<AnimatePresence mode="wait" initial={false}>
|
|
70
|
+
{state === "loading" && (
|
|
71
|
+
<motion.span
|
|
72
|
+
key="loading"
|
|
73
|
+
initial={{ opacity: 0, y: 8 }}
|
|
74
|
+
animate={{ opacity: 1, y: 0 }}
|
|
75
|
+
exit={{ opacity: 0, y: -8 }}
|
|
76
|
+
transition={{ duration: 0.15 }}
|
|
77
|
+
className="flex items-center gap-2"
|
|
78
|
+
>
|
|
79
|
+
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
|
80
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
|
81
|
+
<path className="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
82
|
+
</svg>
|
|
83
|
+
处理中…
|
|
84
|
+
</motion.span>
|
|
85
|
+
)}
|
|
86
|
+
{state === "success" && (
|
|
87
|
+
<motion.span
|
|
88
|
+
key="success"
|
|
89
|
+
initial={{ opacity: 0, y: 8 }}
|
|
90
|
+
animate={{ opacity: 1, y: 0 }}
|
|
91
|
+
exit={{ opacity: 0, y: -8 }}
|
|
92
|
+
transition={{ duration: 0.15 }}
|
|
93
|
+
className="flex items-center gap-2"
|
|
94
|
+
>
|
|
95
|
+
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
|
96
|
+
<polyline points="20 6 9 17 4 12" />
|
|
97
|
+
</svg>
|
|
98
|
+
{successText}
|
|
99
|
+
</motion.span>
|
|
100
|
+
)}
|
|
101
|
+
{state === "idle" && (
|
|
102
|
+
<motion.span
|
|
103
|
+
key="idle"
|
|
104
|
+
initial={{ opacity: 0, y: 8 }}
|
|
105
|
+
animate={{ opacity: 1, y: 0 }}
|
|
106
|
+
exit={{ opacity: 0, y: -8 }}
|
|
107
|
+
transition={{ duration: 0.15 }}
|
|
108
|
+
>
|
|
109
|
+
{children}
|
|
110
|
+
</motion.span>
|
|
111
|
+
)}
|
|
112
|
+
</AnimatePresence>
|
|
113
|
+
</motion.button>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
|
|
6
|
+
export type BannerVariant = "info" | "warning" | "danger";
|
|
7
|
+
|
|
8
|
+
export interface StickyBannerProps extends HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
/** Banner content */
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
/** Visual severity */
|
|
12
|
+
variant?: BannerVariant;
|
|
13
|
+
/** Called when the X button is clicked */
|
|
14
|
+
onDismiss?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const variantStyles: Record<BannerVariant, string> = {
|
|
18
|
+
info: "bg-surface text-ink border-line",
|
|
19
|
+
warning: "bg-ink text-white border-ink",
|
|
20
|
+
danger: "bg-bg text-ink border-ink border-2",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* StickyBanner — a dismissible banner that sticks to the top.
|
|
25
|
+
*
|
|
26
|
+
* Renders a full-width bar pinned to the top of the viewport with an
|
|
27
|
+
* X button. Use `variant` to convey severity. Stays in place on scroll.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* <StickyBanner variant="warning" onDismiss={() => setGone(true)}>
|
|
31
|
+
* 系统将于今晚维护
|
|
32
|
+
* </StickyBanner>
|
|
33
|
+
*/
|
|
34
|
+
export const StickyBanner = forwardRef<HTMLDivElement, StickyBannerProps>(
|
|
35
|
+
({ children, variant = "info", onDismiss, className, ...props }, ref) => {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
ref={ref}
|
|
39
|
+
role="banner"
|
|
40
|
+
className={cn(
|
|
41
|
+
"sticky top-0 z-40 flex w-full items-center justify-between gap-3 border-b px-4 py-2.5 text-sm",
|
|
42
|
+
variantStyles[variant],
|
|
43
|
+
className
|
|
44
|
+
)}
|
|
45
|
+
{...props}
|
|
46
|
+
>
|
|
47
|
+
<div className="flex-1">{children}</div>
|
|
48
|
+
{onDismiss && (
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
aria-label="dismiss"
|
|
52
|
+
onClick={onDismiss}
|
|
53
|
+
className="shrink-0 rounded-md p-1 text-current opacity-60 transition-opacity hover:opacity-100"
|
|
54
|
+
>
|
|
55
|
+
<svg
|
|
56
|
+
className="h-4 w-4"
|
|
57
|
+
viewBox="0 0 24 24"
|
|
58
|
+
fill="none"
|
|
59
|
+
stroke="currentColor"
|
|
60
|
+
strokeWidth="2"
|
|
61
|
+
strokeLinecap="round"
|
|
62
|
+
strokeLinejoin="round"
|
|
63
|
+
>
|
|
64
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
65
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
66
|
+
</svg>
|
|
67
|
+
</button>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
StickyBanner.displayName = "StickyBanner";
|