@zentauri-ui/zentauri-components 1.7.9 → 1.8.1
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/README.md +9 -4
- package/cli/registry.json +3 -0
- package/dist/chunk-7TGUGTTQ.mjs +147 -0
- package/dist/chunk-7TGUGTTQ.mjs.map +1 -0
- package/dist/chunk-CQMV7BB6.js +50 -0
- package/dist/chunk-CQMV7BB6.js.map +1 -0
- package/dist/chunk-DN7TYUJ6.js +119 -0
- package/dist/chunk-DN7TYUJ6.js.map +1 -0
- package/dist/chunk-ODBG4Y6R.mjs +48 -0
- package/dist/chunk-ODBG4Y6R.mjs.map +1 -0
- package/dist/chunk-RKX5MERK.js +150 -0
- package/dist/chunk-RKX5MERK.js.map +1 -0
- package/dist/chunk-VYI3GS2C.mjs +115 -0
- package/dist/chunk-VYI3GS2C.mjs.map +1 -0
- package/dist/design-system/animated-number.d.ts +32 -0
- package/dist/design-system/animated-number.d.ts.map +1 -0
- package/dist/design-system/copy-button.d.ts +43 -0
- package/dist/design-system/copy-button.d.ts.map +1 -0
- package/dist/design-system/index.d.ts +3 -0
- package/dist/design-system/index.d.ts.map +1 -1
- package/dist/design-system/kbd.d.ts +44 -0
- package/dist/design-system/kbd.d.ts.map +1 -0
- package/dist/hooks/useClipboard.js +6 -44
- package/dist/hooks/useClipboard.js.map +1 -1
- package/dist/hooks/useClipboard.mjs +1 -46
- package/dist/hooks/useClipboard.mjs.map +1 -1
- package/dist/ui/animated-number/animated-number.d.ts +4 -0
- package/dist/ui/animated-number/animated-number.d.ts.map +1 -0
- package/dist/ui/animated-number/animations.d.ts +59 -0
- package/dist/ui/animated-number/animations.d.ts.map +1 -0
- package/dist/ui/animated-number/index.d.ts +4 -0
- package/dist/ui/animated-number/index.d.ts.map +1 -0
- package/dist/ui/animated-number/types.d.ts +31 -0
- package/dist/ui/animated-number/types.d.ts.map +1 -0
- package/dist/ui/animated-number/variants.d.ts +5 -0
- package/dist/ui/animated-number/variants.d.ts.map +1 -0
- package/dist/ui/animated-number.js +181 -0
- package/dist/ui/animated-number.js.map +1 -0
- package/dist/ui/animated-number.mjs +177 -0
- package/dist/ui/animated-number.mjs.map +1 -0
- package/dist/ui/copy-button/animated/animations.d.ts +3 -0
- package/dist/ui/copy-button/animated/animations.d.ts.map +1 -0
- package/dist/ui/copy-button/animated/copy-button-animated.d.ts +6 -0
- package/dist/ui/copy-button/animated/copy-button-animated.d.ts.map +1 -0
- package/dist/ui/copy-button/animated/index.d.ts +4 -0
- package/dist/ui/copy-button/animated/index.d.ts.map +1 -0
- package/dist/ui/copy-button/animated/types.d.ts +26 -0
- package/dist/ui/copy-button/animated/types.d.ts.map +1 -0
- package/dist/ui/copy-button/animated.js +59 -0
- package/dist/ui/copy-button/animated.js.map +1 -0
- package/dist/ui/copy-button/animated.mjs +56 -0
- package/dist/ui/copy-button/animated.mjs.map +1 -0
- package/dist/ui/copy-button/copy-button-base.d.ts +6 -0
- package/dist/ui/copy-button/copy-button-base.d.ts.map +1 -0
- package/dist/ui/copy-button/copy-button.d.ts +6 -0
- package/dist/ui/copy-button/copy-button.d.ts.map +1 -0
- package/dist/ui/copy-button/index.d.ts +4 -0
- package/dist/ui/copy-button/index.d.ts.map +1 -0
- package/dist/ui/copy-button/types.d.ts +32 -0
- package/dist/ui/copy-button/types.d.ts.map +1 -0
- package/dist/ui/copy-button/variants.d.ts +6 -0
- package/dist/ui/copy-button/variants.d.ts.map +1 -0
- package/dist/ui/copy-button.js +20 -0
- package/dist/ui/copy-button.js.map +1 -0
- package/dist/ui/copy-button.mjs +15 -0
- package/dist/ui/copy-button.mjs.map +1 -0
- package/dist/ui/kbd/animated/animations.d.ts +3 -0
- package/dist/ui/kbd/animated/animations.d.ts.map +1 -0
- package/dist/ui/kbd/animated/index.d.ts +4 -0
- package/dist/ui/kbd/animated/index.d.ts.map +1 -0
- package/dist/ui/kbd/animated/kbd-animated.d.ts +6 -0
- package/dist/ui/kbd/animated/kbd-animated.d.ts.map +1 -0
- package/dist/ui/kbd/animated/types.d.ts +10 -0
- package/dist/ui/kbd/animated/types.d.ts.map +1 -0
- package/dist/ui/kbd/animated.js +42 -0
- package/dist/ui/kbd/animated.js.map +1 -0
- package/dist/ui/kbd/animated.mjs +39 -0
- package/dist/ui/kbd/animated.mjs.map +1 -0
- package/dist/ui/kbd/index.d.ts +4 -0
- package/dist/ui/kbd/index.d.ts.map +1 -0
- package/dist/ui/kbd/kbd-base.d.ts +6 -0
- package/dist/ui/kbd/kbd-base.d.ts.map +1 -0
- package/dist/ui/kbd/kbd.d.ts +6 -0
- package/dist/ui/kbd/kbd.d.ts.map +1 -0
- package/dist/ui/kbd/types.d.ts +17 -0
- package/dist/ui/kbd/types.d.ts.map +1 -0
- package/dist/ui/kbd/variants.d.ts +8 -0
- package/dist/ui/kbd/variants.d.ts.map +1 -0
- package/dist/ui/kbd.js +23 -0
- package/dist/ui/kbd.js.map +1 -0
- package/dist/ui/kbd.mjs +14 -0
- package/dist/ui/kbd.mjs.map +1 -0
- package/package.json +1 -1
- package/src/design-system/animated-number.ts +53 -0
- package/src/design-system/copy-button.ts +81 -0
- package/src/design-system/index.ts +3 -0
- package/src/design-system/kbd.ts +83 -0
- package/src/ui/animated-number/animated-number.test.tsx +64 -0
- package/src/ui/animated-number/animated-number.tsx +120 -0
- package/src/ui/animated-number/animations.ts +22 -0
- package/src/ui/animated-number/index.ts +4 -0
- package/src/ui/animated-number/types.ts +39 -0
- package/src/ui/animated-number/variants.ts +14 -0
- package/src/ui/copy-button/animated/animations.ts +22 -0
- package/src/ui/copy-button/animated/copy-button-animated.tsx +39 -0
- package/src/ui/copy-button/animated/index.ts +10 -0
- package/src/ui/copy-button/animated/types.ts +21 -0
- package/src/ui/copy-button/copy-button-base.tsx +88 -0
- package/src/ui/copy-button/copy-button.test.tsx +82 -0
- package/src/ui/copy-button/copy-button.tsx +9 -0
- package/src/ui/copy-button/index.ts +10 -0
- package/src/ui/copy-button/types.ts +37 -0
- package/src/ui/copy-button/variants.ts +29 -0
- package/src/ui/kbd/animated/animations.ts +15 -0
- package/src/ui/kbd/animated/index.ts +9 -0
- package/src/ui/kbd/animated/kbd-animated.tsx +26 -0
- package/src/ui/kbd/animated/types.ts +16 -0
- package/src/ui/kbd/index.ts +5 -0
- package/src/ui/kbd/kbd-base.tsx +50 -0
- package/src/ui/kbd/kbd.test.tsx +48 -0
- package/src/ui/kbd/kbd.tsx +9 -0
- package/src/ui/kbd/types.ts +21 -0
- package/src/ui/kbd/variants.ts +31 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { animate, motion, useInView, useReducedMotion, type UseInViewOptions } from "framer-motion";
|
|
3
|
+
import { animatedNumberAppearance } from "./variants";
|
|
4
|
+
import { AnimatedNumberCounterProps, AnimatedNumberProps } from "./types";
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
6
|
+
import { zuiAnimatedNumberBase } from "../../design-system/animated-number";
|
|
7
|
+
import { animationFinalType, animationInitialType } from "./animations";
|
|
8
|
+
import { useEffect, useRef, useState } from "react";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_VIEWPORT = { once: true, amount: 0.2 } as const;
|
|
11
|
+
|
|
12
|
+
export const AnimatedNumber = ({
|
|
13
|
+
number,
|
|
14
|
+
wrapperClassName,
|
|
15
|
+
className,
|
|
16
|
+
ref,
|
|
17
|
+
appearance,
|
|
18
|
+
size,
|
|
19
|
+
type = "up",
|
|
20
|
+
delayInSecond = 0.1,
|
|
21
|
+
transition,
|
|
22
|
+
initial,
|
|
23
|
+
whileInView,
|
|
24
|
+
viewport,
|
|
25
|
+
...rest
|
|
26
|
+
}: AnimatedNumberProps) => {
|
|
27
|
+
const numbersList = [...number.toString()];
|
|
28
|
+
const reducedMotion = useReducedMotion();
|
|
29
|
+
const motionless = Boolean(reducedMotion);
|
|
30
|
+
|
|
31
|
+
const digitVariants = {
|
|
32
|
+
hidden: animationInitialType[type],
|
|
33
|
+
visible: animationFinalType[type],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<motion.div
|
|
38
|
+
ref={ref}
|
|
39
|
+
initial={motionless ? false : "hidden"}
|
|
40
|
+
whileInView={motionless ? undefined : "visible"}
|
|
41
|
+
viewport={viewport ?? DEFAULT_VIEWPORT}
|
|
42
|
+
transition={{
|
|
43
|
+
staggerChildren: delayInSecond,
|
|
44
|
+
}}
|
|
45
|
+
className={cn(wrapperClassName, zuiAnimatedNumberBase)}
|
|
46
|
+
>
|
|
47
|
+
{numbersList.map((digit, index) => (
|
|
48
|
+
<motion.span
|
|
49
|
+
key={index + "-" + digit}
|
|
50
|
+
className={cn(
|
|
51
|
+
"inline-block",
|
|
52
|
+
animatedNumberAppearance({ appearance, size }),
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
variants={digitVariants}
|
|
56
|
+
transition={transition}
|
|
57
|
+
{...rest}
|
|
58
|
+
>
|
|
59
|
+
{digit}
|
|
60
|
+
</motion.span>
|
|
61
|
+
))}
|
|
62
|
+
</motion.div>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const AnimatedNumberCounter = ({
|
|
67
|
+
number,
|
|
68
|
+
className,
|
|
69
|
+
ref: externalRef,
|
|
70
|
+
appearance,
|
|
71
|
+
size,
|
|
72
|
+
duration = 2,
|
|
73
|
+
viewport,
|
|
74
|
+
...rest
|
|
75
|
+
}: AnimatedNumberCounterProps) => {
|
|
76
|
+
const [currentNumber, setCurrentNumber] = useState(0);
|
|
77
|
+
const reducedMotion = useReducedMotion();
|
|
78
|
+
const internalRef = useRef<HTMLParagraphElement>(null);
|
|
79
|
+
// once: false gives real two-way tracking so isInView flips false when scrolled away,
|
|
80
|
+
// preventing offscreen animations when the number prop changes later.
|
|
81
|
+
const isInView = useInView(internalRef, {
|
|
82
|
+
once: false,
|
|
83
|
+
amount: 0.2,
|
|
84
|
+
...viewport,
|
|
85
|
+
} as UseInViewOptions);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!isInView) return;
|
|
89
|
+
|
|
90
|
+
if (reducedMotion) {
|
|
91
|
+
setCurrentNumber(number);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const controls = animate(currentNumber, number, {
|
|
96
|
+
duration,
|
|
97
|
+
ease: "circOut",
|
|
98
|
+
onUpdate: (latest) => setCurrentNumber(Math.round(latest)),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return () => controls.stop();
|
|
102
|
+
// currentNumber intentionally omitted — captured value gives smooth from→to on prop changes
|
|
103
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
104
|
+
}, [isInView, number, duration, reducedMotion]);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<motion.p
|
|
108
|
+
className={cn(animatedNumberAppearance({ appearance, size }), className)}
|
|
109
|
+
ref={(node: HTMLParagraphElement) => {
|
|
110
|
+
internalRef.current = node;
|
|
111
|
+
if (externalRef) {
|
|
112
|
+
externalRef.current = node;
|
|
113
|
+
}
|
|
114
|
+
}}
|
|
115
|
+
{...rest}
|
|
116
|
+
>
|
|
117
|
+
{currentNumber}
|
|
118
|
+
</motion.p>
|
|
119
|
+
);
|
|
120
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const animationInitialType = {
|
|
2
|
+
up: { y: "-100%" },
|
|
3
|
+
down: { y: "100%" },
|
|
4
|
+
scaleUp: { scale: 0 },
|
|
5
|
+
scaleDown: { scale: 1.25 },
|
|
6
|
+
rotateX: { rotateX: "0" },
|
|
7
|
+
rotateY: { rotateY: "0" },
|
|
8
|
+
skewX: { skewX: 20 },
|
|
9
|
+
skewY: { skewY: 20 },
|
|
10
|
+
fade: { opacity: 0 },
|
|
11
|
+
};
|
|
12
|
+
export const animationFinalType = {
|
|
13
|
+
up: { y: 0 },
|
|
14
|
+
down: { y: 0 },
|
|
15
|
+
scaleUp: { scale: 1 },
|
|
16
|
+
scaleDown: { scale: 1 },
|
|
17
|
+
rotateX: { rotateX: "360deg" },
|
|
18
|
+
rotateY: { rotateY: "360deg" },
|
|
19
|
+
skewX: { skewX: 0 },
|
|
20
|
+
skewY: { skewY: 0 },
|
|
21
|
+
fade: { opacity: 1 },
|
|
22
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { VariantProps } from "class-variance-authority";
|
|
2
|
+
import { MotionProps } from "framer-motion";
|
|
3
|
+
import { RefObject } from "react";
|
|
4
|
+
import { animatedNumberAppearance } from "./variants";
|
|
5
|
+
|
|
6
|
+
type MotionTransitionWithoutDelay = Omit<
|
|
7
|
+
NonNullable<MotionProps["transition"]>,
|
|
8
|
+
"delay"
|
|
9
|
+
> & {
|
|
10
|
+
delay?: never;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type MotionPropsWithoutTransitionDelay = Omit<
|
|
14
|
+
MotionProps,
|
|
15
|
+
"transition"
|
|
16
|
+
> & {
|
|
17
|
+
transition?: MotionTransitionWithoutDelay;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type AnimatedNumberProps = MotionPropsWithoutTransitionDelay & {
|
|
21
|
+
number: number;
|
|
22
|
+
wrapperClassName?: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
ref?: RefObject<HTMLDivElement>;
|
|
25
|
+
appearance?: VariantProps<typeof animatedNumberAppearance>["appearance"];
|
|
26
|
+
size?: VariantProps<typeof animatedNumberAppearance>["size"];
|
|
27
|
+
type?: "up" | "down" | "scaleUp" | "scaleDown" | "rotateX" | "rotateY" | "skewX" | "skewY" | "fade";
|
|
28
|
+
delayInSecond?: number;
|
|
29
|
+
transition?: MotionProps["transition"];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type AnimatedNumberCounterProps = MotionProps & {
|
|
33
|
+
number: number;
|
|
34
|
+
className?: string;
|
|
35
|
+
ref?: RefObject<HTMLParagraphElement>;
|
|
36
|
+
appearance?: VariantProps<typeof animatedNumberAppearance>["appearance"];
|
|
37
|
+
size?: VariantProps<typeof animatedNumberAppearance>["size"];
|
|
38
|
+
duration?: number;
|
|
39
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { cva } from "class-variance-authority";
|
|
2
|
+
|
|
3
|
+
import { zuiAnimatedNumberAppearance, zuiAnimatedNumberSize } from "../../design-system/animated-number";
|
|
4
|
+
|
|
5
|
+
export const animatedNumberAppearance = cva("inline-flex",{
|
|
6
|
+
variants: {
|
|
7
|
+
appearance: zuiAnimatedNumberAppearance,
|
|
8
|
+
size: zuiAnimatedNumberSize
|
|
9
|
+
},
|
|
10
|
+
defaultVariants: {
|
|
11
|
+
appearance: "default",
|
|
12
|
+
size: "md",
|
|
13
|
+
},
|
|
14
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CopyButtonAnimationPresets } from "./types";
|
|
2
|
+
|
|
3
|
+
export const copyButtonAnimationPresets: CopyButtonAnimationPresets = {
|
|
4
|
+
swap: {
|
|
5
|
+
initial: { opacity: 0, scale: 0.6, rotate: -45 },
|
|
6
|
+
animate: { opacity: 1, scale: 1, rotate: 0 },
|
|
7
|
+
exit: { opacity: 0, scale: 0.6, rotate: 45 },
|
|
8
|
+
transition: { type: "spring", stiffness: 520, damping: 24 },
|
|
9
|
+
},
|
|
10
|
+
pop: {
|
|
11
|
+
initial: { opacity: 0, scale: 0.4, rotate: 0 },
|
|
12
|
+
animate: { opacity: 1, scale: 1, rotate: 0 },
|
|
13
|
+
exit: { opacity: 0, scale: 0.4, rotate: 0 },
|
|
14
|
+
transition: { type: "spring", stiffness: 600, damping: 20 },
|
|
15
|
+
},
|
|
16
|
+
fade: {
|
|
17
|
+
initial: { opacity: 0, scale: 1, rotate: 0 },
|
|
18
|
+
animate: { opacity: 1, scale: 1, rotate: 0 },
|
|
19
|
+
exit: { opacity: 0, scale: 1, rotate: 0 },
|
|
20
|
+
transition: { duration: 0.16 },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
4
|
+
|
|
5
|
+
import { CopyButtonBase } from "../copy-button-base";
|
|
6
|
+
import type { CopyButtonIconRenderer } from "../types";
|
|
7
|
+
|
|
8
|
+
import { copyButtonAnimationPresets } from "./animations";
|
|
9
|
+
import type { CopyButtonAnimatedProps } from "./types";
|
|
10
|
+
|
|
11
|
+
export function CopyButtonAnimated({
|
|
12
|
+
animation = "swap",
|
|
13
|
+
...props
|
|
14
|
+
}: CopyButtonAnimatedProps) {
|
|
15
|
+
const preset = copyButtonAnimationPresets[animation];
|
|
16
|
+
|
|
17
|
+
const renderIcon: CopyButtonIconRenderer = ({
|
|
18
|
+
copied,
|
|
19
|
+
copyIcon,
|
|
20
|
+
copiedIcon,
|
|
21
|
+
}) => (
|
|
22
|
+
<AnimatePresence initial={false} mode="wait">
|
|
23
|
+
<motion.span
|
|
24
|
+
key={copied ? "copied" : "idle"}
|
|
25
|
+
className="inline-flex items-center justify-center"
|
|
26
|
+
initial={preset.initial}
|
|
27
|
+
animate={preset.animate}
|
|
28
|
+
exit={preset.exit}
|
|
29
|
+
transition={preset.transition}
|
|
30
|
+
>
|
|
31
|
+
{copied ? copiedIcon : copyIcon}
|
|
32
|
+
</motion.span>
|
|
33
|
+
</AnimatePresence>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return <CopyButtonBase {...props} renderIcon={renderIcon} />;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
CopyButtonAnimated.displayName = "CopyButtonAnimated";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
export { CopyButtonAnimated } from "./copy-button-animated";
|
|
4
|
+
export { copyButtonAnimationPresets } from "./animations";
|
|
5
|
+
export type {
|
|
6
|
+
CopyButtonAnimatedProps,
|
|
7
|
+
CopyButtonAnimation,
|
|
8
|
+
CopyButtonAnimationPreset,
|
|
9
|
+
CopyButtonAnimationPresets,
|
|
10
|
+
} from "./types";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Transition } from "framer-motion";
|
|
2
|
+
|
|
3
|
+
import type { CopyButtonProps } from "../types";
|
|
4
|
+
|
|
5
|
+
export type CopyButtonAnimation = "swap" | "pop" | "fade";
|
|
6
|
+
|
|
7
|
+
export type CopyButtonAnimatedProps = CopyButtonProps & {
|
|
8
|
+
animation?: CopyButtonAnimation;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type CopyButtonAnimationPreset = {
|
|
12
|
+
initial: { opacity: number; scale: number; rotate: number };
|
|
13
|
+
animate: { opacity: number; scale: number; rotate: number };
|
|
14
|
+
exit: { opacity: number; scale: number; rotate: number };
|
|
15
|
+
transition: Transition;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type CopyButtonAnimationPresets = Record<
|
|
19
|
+
CopyButtonAnimation,
|
|
20
|
+
CopyButtonAnimationPreset
|
|
21
|
+
>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { FiCheck, FiCopy } from "react-icons/fi";
|
|
4
|
+
|
|
5
|
+
import { useClipboard } from "../../hooks/useClipboard/useClipboard";
|
|
6
|
+
import { cn } from "../../lib/utils";
|
|
7
|
+
|
|
8
|
+
import type { CopyButtonBaseProps, CopyButtonIconRenderer } from "./types";
|
|
9
|
+
import { copyButtonVariants } from "./variants";
|
|
10
|
+
|
|
11
|
+
const defaultRenderIcon: CopyButtonIconRenderer = ({
|
|
12
|
+
copied,
|
|
13
|
+
copyIcon,
|
|
14
|
+
copiedIcon,
|
|
15
|
+
}) => (copied ? copiedIcon : copyIcon);
|
|
16
|
+
|
|
17
|
+
export function CopyButtonBase({
|
|
18
|
+
value,
|
|
19
|
+
timeout = 2000,
|
|
20
|
+
appearance,
|
|
21
|
+
size,
|
|
22
|
+
iconOnly = true,
|
|
23
|
+
label = "Copy",
|
|
24
|
+
copiedLabel = "Copied",
|
|
25
|
+
copyIcon = <FiCopy aria-hidden />,
|
|
26
|
+
copiedIcon = <FiCheck aria-hidden />,
|
|
27
|
+
onCopy,
|
|
28
|
+
renderIcon = defaultRenderIcon,
|
|
29
|
+
className,
|
|
30
|
+
type = "button",
|
|
31
|
+
disabled,
|
|
32
|
+
onClick,
|
|
33
|
+
"aria-label": ariaLabel,
|
|
34
|
+
ref,
|
|
35
|
+
...rest
|
|
36
|
+
}: CopyButtonBaseProps) {
|
|
37
|
+
const { copied, copy } = useClipboard(timeout);
|
|
38
|
+
|
|
39
|
+
const handleClick: NonNullable<CopyButtonBaseProps["onClick"]> = async (
|
|
40
|
+
event,
|
|
41
|
+
) => {
|
|
42
|
+
onClick?.(event);
|
|
43
|
+
if (event.defaultPrevented) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const ok = await copy(value);
|
|
47
|
+
if (ok) {
|
|
48
|
+
onCopy?.(value);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const text = copied ? copiedLabel : label;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<button
|
|
56
|
+
ref={ref}
|
|
57
|
+
type={type}
|
|
58
|
+
data-slot="copy-button"
|
|
59
|
+
data-copied={copied ? "true" : undefined}
|
|
60
|
+
disabled={disabled}
|
|
61
|
+
aria-label={ariaLabel ?? (iconOnly ? text : undefined)}
|
|
62
|
+
onClick={handleClick}
|
|
63
|
+
className={cn(
|
|
64
|
+
copyButtonVariants({ appearance, size, iconOnly }),
|
|
65
|
+
className,
|
|
66
|
+
)}
|
|
67
|
+
{...rest}
|
|
68
|
+
>
|
|
69
|
+
<span
|
|
70
|
+
data-slot="copy-button-icon"
|
|
71
|
+
className="relative inline-flex items-center justify-center"
|
|
72
|
+
>
|
|
73
|
+
{renderIcon({ copied, copyIcon, copiedIcon })}
|
|
74
|
+
</span>
|
|
75
|
+
{!iconOnly ? (
|
|
76
|
+
<span data-slot="copy-button-label" aria-live="polite">
|
|
77
|
+
{text}
|
|
78
|
+
</span>
|
|
79
|
+
) : (
|
|
80
|
+
<span className="sr-only" aria-live="polite">
|
|
81
|
+
{text}
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
</button>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
CopyButtonBase.displayName = "CopyButton";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { CopyButton } from "./copy-button";
|
|
7
|
+
|
|
8
|
+
describe("CopyButton", () => {
|
|
9
|
+
const originalClipboard = navigator.clipboard;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
13
|
+
configurable: true,
|
|
14
|
+
writable: true,
|
|
15
|
+
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
21
|
+
configurable: true,
|
|
22
|
+
writable: true,
|
|
23
|
+
value: originalClipboard,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should expose displayName", () => {
|
|
28
|
+
expect(CopyButton.displayName).toBe("CopyButton");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should stamp data-slot", () => {
|
|
32
|
+
render(<CopyButton value="npm i zentauri" />);
|
|
33
|
+
expect(
|
|
34
|
+
document.querySelector('[data-slot="copy-button"]'),
|
|
35
|
+
).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should write the value to the clipboard on click", async () => {
|
|
39
|
+
render(<CopyButton value="copy me" />);
|
|
40
|
+
fireEvent.click(screen.getByRole("button"));
|
|
41
|
+
await waitFor(() =>
|
|
42
|
+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("copy me"),
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should call onCopy after a successful copy", async () => {
|
|
47
|
+
const user = userEvent.setup();
|
|
48
|
+
const onCopy = vi.fn();
|
|
49
|
+
render(<CopyButton value="token-123" onCopy={onCopy} />);
|
|
50
|
+
await user.click(screen.getByRole("button"));
|
|
51
|
+
await waitFor(() => expect(onCopy).toHaveBeenCalledWith("token-123"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should flip to the copied state and mark data-copied", async () => {
|
|
55
|
+
const user = userEvent.setup();
|
|
56
|
+
render(<CopyButton value="x" timeout={0} copiedLabel="Copied!" />);
|
|
57
|
+
const button = screen.getByRole("button");
|
|
58
|
+
await user.click(button);
|
|
59
|
+
await waitFor(() =>
|
|
60
|
+
expect(button.getAttribute("data-copied")).toBe("true"),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should render the label when iconOnly is false", () => {
|
|
65
|
+
render(<CopyButton value="x" iconOnly={false} label="Copy code" />);
|
|
66
|
+
expect(screen.getByText("Copy code")).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should apply the secondary appearance token", () => {
|
|
70
|
+
render(<CopyButton value="x" appearance="secondary" />);
|
|
71
|
+
const button = document.querySelector(
|
|
72
|
+
'[data-slot="copy-button"]',
|
|
73
|
+
) as HTMLElement;
|
|
74
|
+
expect(button.className).toMatch(/--zui-copy-button-secondary-bg/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should forward ref", () => {
|
|
78
|
+
const ref = createRef<HTMLButtonElement>();
|
|
79
|
+
render(<CopyButton ref={ref} value="x" />);
|
|
80
|
+
expect(ref.current?.getAttribute("data-slot")).toBe("copy-button");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// copy-button.tsx — default static entry (no framer-motion)
|
|
2
|
+
import { CopyButtonBase } from "./copy-button-base";
|
|
3
|
+
import type { CopyButtonProps } from "./types";
|
|
4
|
+
|
|
5
|
+
export function CopyButton(props: CopyButtonProps) {
|
|
6
|
+
return <CopyButtonBase {...props} />;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
CopyButton.displayName = "CopyButton";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { VariantProps } from "class-variance-authority";
|
|
2
|
+
import type { ComponentPropsWithRef, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import type { copyButtonVariants } from "./variants";
|
|
5
|
+
|
|
6
|
+
export type CopyButtonVariantProps = VariantProps<typeof copyButtonVariants>;
|
|
7
|
+
|
|
8
|
+
/** Renders the icon region for a given copied state. Lets the animated entry swap the static icons for motion ones. */
|
|
9
|
+
export type CopyButtonIconRenderer = (state: {
|
|
10
|
+
copied: boolean;
|
|
11
|
+
copyIcon: ReactNode;
|
|
12
|
+
copiedIcon: ReactNode;
|
|
13
|
+
}) => ReactNode;
|
|
14
|
+
|
|
15
|
+
export interface CopyButtonBaseProps
|
|
16
|
+
extends Omit<ComponentPropsWithRef<"button">, "value" | "onCopy"> {
|
|
17
|
+
/** Text written to the clipboard when the button is pressed. */
|
|
18
|
+
value: string;
|
|
19
|
+
/** Milliseconds the copied state stays active before resetting. `0` keeps it until re-copied. */
|
|
20
|
+
timeout?: number;
|
|
21
|
+
appearance?: CopyButtonVariantProps["appearance"];
|
|
22
|
+
size?: CopyButtonVariantProps["size"];
|
|
23
|
+
/** Render only the icon (default). Pass `false` to show the label text alongside the icon. */
|
|
24
|
+
iconOnly?: boolean;
|
|
25
|
+
/** Label shown (and used for `aria-label`) in the idle state. */
|
|
26
|
+
label?: string;
|
|
27
|
+
/** Label shown (and used for `aria-label`) after a successful copy. */
|
|
28
|
+
copiedLabel?: string;
|
|
29
|
+
copyIcon?: ReactNode;
|
|
30
|
+
copiedIcon?: ReactNode;
|
|
31
|
+
/** Called with `value` after the clipboard write succeeds. */
|
|
32
|
+
onCopy?: (value: string) => void;
|
|
33
|
+
/** Overrides how the icon region renders; the animated entry uses this for motion. */
|
|
34
|
+
renderIcon?: CopyButtonIconRenderer;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type CopyButtonProps = Omit<CopyButtonBaseProps, "renderIcon">;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { cva } from "class-variance-authority";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
zuiCopyButtonAppearances,
|
|
5
|
+
zuiCopyButtonBase,
|
|
6
|
+
zuiCopyButtonIconOnlySizes,
|
|
7
|
+
zuiCopyButtonSizes,
|
|
8
|
+
} from "../../design-system/copy-button";
|
|
9
|
+
|
|
10
|
+
export const copyButtonVariants = cva(zuiCopyButtonBase, {
|
|
11
|
+
variants: {
|
|
12
|
+
appearance: zuiCopyButtonAppearances,
|
|
13
|
+
size: zuiCopyButtonSizes,
|
|
14
|
+
iconOnly: {
|
|
15
|
+
true: "",
|
|
16
|
+
false: "",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
compoundVariants: [
|
|
20
|
+
{ iconOnly: true, size: "sm", class: zuiCopyButtonIconOnlySizes.sm },
|
|
21
|
+
{ iconOnly: true, size: "md", class: zuiCopyButtonIconOnlySizes.md },
|
|
22
|
+
{ iconOnly: true, size: "lg", class: zuiCopyButtonIconOnlySizes.lg },
|
|
23
|
+
],
|
|
24
|
+
defaultVariants: {
|
|
25
|
+
appearance: "default",
|
|
26
|
+
size: "md",
|
|
27
|
+
iconOnly: true,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { KbdAnimationPresets } from "./types";
|
|
2
|
+
|
|
3
|
+
export const kbdAnimationPresets: KbdAnimationPresets = {
|
|
4
|
+
none: {},
|
|
5
|
+
press: {
|
|
6
|
+
whileHover: { y: -1 },
|
|
7
|
+
whileTap: { y: 1, scale: 0.96 },
|
|
8
|
+
transition: { type: "spring", stiffness: 600, damping: 22 },
|
|
9
|
+
},
|
|
10
|
+
pop: {
|
|
11
|
+
initial: { scale: 0.85, opacity: 0 },
|
|
12
|
+
animate: { scale: 1, opacity: 1 },
|
|
13
|
+
transition: { type: "spring", stiffness: 520, damping: 26 },
|
|
14
|
+
},
|
|
15
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion } from "framer-motion";
|
|
4
|
+
|
|
5
|
+
import { KbdBase } from "../kbd-base";
|
|
6
|
+
import type { KbdBaseProps } from "../types";
|
|
7
|
+
|
|
8
|
+
import { kbdAnimationPresets } from "./animations";
|
|
9
|
+
import type { KbdAnimatedProps } from "./types";
|
|
10
|
+
|
|
11
|
+
export function KbdAnimated({ animation = "none", ...props }: KbdAnimatedProps) {
|
|
12
|
+
const motionProps = kbdAnimationPresets[animation];
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<KbdBase
|
|
16
|
+
{...({
|
|
17
|
+
as: motion.span,
|
|
18
|
+
initial: animation === "none" ? false : undefined,
|
|
19
|
+
...motionProps,
|
|
20
|
+
...props,
|
|
21
|
+
} as KbdBaseProps)}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
KbdAnimated.displayName = "KbdAnimated";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { HTMLMotionProps } from "framer-motion";
|
|
2
|
+
|
|
3
|
+
import type { KbdBaseProps } from "../types";
|
|
4
|
+
|
|
5
|
+
export type KbdAnimation = "none" | "press" | "pop";
|
|
6
|
+
|
|
7
|
+
export type KbdAnimatedProps = Omit<KbdBaseProps, "as"> & {
|
|
8
|
+
animation?: KbdAnimation;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type KbdPresetMotionProps = Pick<
|
|
12
|
+
HTMLMotionProps<"span">,
|
|
13
|
+
"transition" | "whileHover" | "whileTap" | "animate" | "initial"
|
|
14
|
+
>;
|
|
15
|
+
|
|
16
|
+
export type KbdAnimationPresets = Record<KbdAnimation, KbdPresetMotionProps>;
|