@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.
Files changed (123) hide show
  1. package/README.md +9 -4
  2. package/cli/registry.json +3 -0
  3. package/dist/chunk-7TGUGTTQ.mjs +147 -0
  4. package/dist/chunk-7TGUGTTQ.mjs.map +1 -0
  5. package/dist/chunk-CQMV7BB6.js +50 -0
  6. package/dist/chunk-CQMV7BB6.js.map +1 -0
  7. package/dist/chunk-DN7TYUJ6.js +119 -0
  8. package/dist/chunk-DN7TYUJ6.js.map +1 -0
  9. package/dist/chunk-ODBG4Y6R.mjs +48 -0
  10. package/dist/chunk-ODBG4Y6R.mjs.map +1 -0
  11. package/dist/chunk-RKX5MERK.js +150 -0
  12. package/dist/chunk-RKX5MERK.js.map +1 -0
  13. package/dist/chunk-VYI3GS2C.mjs +115 -0
  14. package/dist/chunk-VYI3GS2C.mjs.map +1 -0
  15. package/dist/design-system/animated-number.d.ts +32 -0
  16. package/dist/design-system/animated-number.d.ts.map +1 -0
  17. package/dist/design-system/copy-button.d.ts +43 -0
  18. package/dist/design-system/copy-button.d.ts.map +1 -0
  19. package/dist/design-system/index.d.ts +3 -0
  20. package/dist/design-system/index.d.ts.map +1 -1
  21. package/dist/design-system/kbd.d.ts +44 -0
  22. package/dist/design-system/kbd.d.ts.map +1 -0
  23. package/dist/hooks/useClipboard.js +6 -44
  24. package/dist/hooks/useClipboard.js.map +1 -1
  25. package/dist/hooks/useClipboard.mjs +1 -46
  26. package/dist/hooks/useClipboard.mjs.map +1 -1
  27. package/dist/ui/animated-number/animated-number.d.ts +4 -0
  28. package/dist/ui/animated-number/animated-number.d.ts.map +1 -0
  29. package/dist/ui/animated-number/animations.d.ts +59 -0
  30. package/dist/ui/animated-number/animations.d.ts.map +1 -0
  31. package/dist/ui/animated-number/index.d.ts +4 -0
  32. package/dist/ui/animated-number/index.d.ts.map +1 -0
  33. package/dist/ui/animated-number/types.d.ts +31 -0
  34. package/dist/ui/animated-number/types.d.ts.map +1 -0
  35. package/dist/ui/animated-number/variants.d.ts +5 -0
  36. package/dist/ui/animated-number/variants.d.ts.map +1 -0
  37. package/dist/ui/animated-number.js +181 -0
  38. package/dist/ui/animated-number.js.map +1 -0
  39. package/dist/ui/animated-number.mjs +177 -0
  40. package/dist/ui/animated-number.mjs.map +1 -0
  41. package/dist/ui/copy-button/animated/animations.d.ts +3 -0
  42. package/dist/ui/copy-button/animated/animations.d.ts.map +1 -0
  43. package/dist/ui/copy-button/animated/copy-button-animated.d.ts +6 -0
  44. package/dist/ui/copy-button/animated/copy-button-animated.d.ts.map +1 -0
  45. package/dist/ui/copy-button/animated/index.d.ts +4 -0
  46. package/dist/ui/copy-button/animated/index.d.ts.map +1 -0
  47. package/dist/ui/copy-button/animated/types.d.ts +26 -0
  48. package/dist/ui/copy-button/animated/types.d.ts.map +1 -0
  49. package/dist/ui/copy-button/animated.js +59 -0
  50. package/dist/ui/copy-button/animated.js.map +1 -0
  51. package/dist/ui/copy-button/animated.mjs +56 -0
  52. package/dist/ui/copy-button/animated.mjs.map +1 -0
  53. package/dist/ui/copy-button/copy-button-base.d.ts +6 -0
  54. package/dist/ui/copy-button/copy-button-base.d.ts.map +1 -0
  55. package/dist/ui/copy-button/copy-button.d.ts +6 -0
  56. package/dist/ui/copy-button/copy-button.d.ts.map +1 -0
  57. package/dist/ui/copy-button/index.d.ts +4 -0
  58. package/dist/ui/copy-button/index.d.ts.map +1 -0
  59. package/dist/ui/copy-button/types.d.ts +32 -0
  60. package/dist/ui/copy-button/types.d.ts.map +1 -0
  61. package/dist/ui/copy-button/variants.d.ts +6 -0
  62. package/dist/ui/copy-button/variants.d.ts.map +1 -0
  63. package/dist/ui/copy-button.js +20 -0
  64. package/dist/ui/copy-button.js.map +1 -0
  65. package/dist/ui/copy-button.mjs +15 -0
  66. package/dist/ui/copy-button.mjs.map +1 -0
  67. package/dist/ui/kbd/animated/animations.d.ts +3 -0
  68. package/dist/ui/kbd/animated/animations.d.ts.map +1 -0
  69. package/dist/ui/kbd/animated/index.d.ts +4 -0
  70. package/dist/ui/kbd/animated/index.d.ts.map +1 -0
  71. package/dist/ui/kbd/animated/kbd-animated.d.ts +6 -0
  72. package/dist/ui/kbd/animated/kbd-animated.d.ts.map +1 -0
  73. package/dist/ui/kbd/animated/types.d.ts +10 -0
  74. package/dist/ui/kbd/animated/types.d.ts.map +1 -0
  75. package/dist/ui/kbd/animated.js +42 -0
  76. package/dist/ui/kbd/animated.js.map +1 -0
  77. package/dist/ui/kbd/animated.mjs +39 -0
  78. package/dist/ui/kbd/animated.mjs.map +1 -0
  79. package/dist/ui/kbd/index.d.ts +4 -0
  80. package/dist/ui/kbd/index.d.ts.map +1 -0
  81. package/dist/ui/kbd/kbd-base.d.ts +6 -0
  82. package/dist/ui/kbd/kbd-base.d.ts.map +1 -0
  83. package/dist/ui/kbd/kbd.d.ts +6 -0
  84. package/dist/ui/kbd/kbd.d.ts.map +1 -0
  85. package/dist/ui/kbd/types.d.ts +17 -0
  86. package/dist/ui/kbd/types.d.ts.map +1 -0
  87. package/dist/ui/kbd/variants.d.ts +8 -0
  88. package/dist/ui/kbd/variants.d.ts.map +1 -0
  89. package/dist/ui/kbd.js +23 -0
  90. package/dist/ui/kbd.js.map +1 -0
  91. package/dist/ui/kbd.mjs +14 -0
  92. package/dist/ui/kbd.mjs.map +1 -0
  93. package/package.json +1 -1
  94. package/src/design-system/animated-number.ts +53 -0
  95. package/src/design-system/copy-button.ts +81 -0
  96. package/src/design-system/index.ts +3 -0
  97. package/src/design-system/kbd.ts +83 -0
  98. package/src/ui/animated-number/animated-number.test.tsx +64 -0
  99. package/src/ui/animated-number/animated-number.tsx +120 -0
  100. package/src/ui/animated-number/animations.ts +22 -0
  101. package/src/ui/animated-number/index.ts +4 -0
  102. package/src/ui/animated-number/types.ts +39 -0
  103. package/src/ui/animated-number/variants.ts +14 -0
  104. package/src/ui/copy-button/animated/animations.ts +22 -0
  105. package/src/ui/copy-button/animated/copy-button-animated.tsx +39 -0
  106. package/src/ui/copy-button/animated/index.ts +10 -0
  107. package/src/ui/copy-button/animated/types.ts +21 -0
  108. package/src/ui/copy-button/copy-button-base.tsx +88 -0
  109. package/src/ui/copy-button/copy-button.test.tsx +82 -0
  110. package/src/ui/copy-button/copy-button.tsx +9 -0
  111. package/src/ui/copy-button/index.ts +10 -0
  112. package/src/ui/copy-button/types.ts +37 -0
  113. package/src/ui/copy-button/variants.ts +29 -0
  114. package/src/ui/kbd/animated/animations.ts +15 -0
  115. package/src/ui/kbd/animated/index.ts +9 -0
  116. package/src/ui/kbd/animated/kbd-animated.tsx +26 -0
  117. package/src/ui/kbd/animated/types.ts +16 -0
  118. package/src/ui/kbd/index.ts +5 -0
  119. package/src/ui/kbd/kbd-base.tsx +50 -0
  120. package/src/ui/kbd/kbd.test.tsx +48 -0
  121. package/src/ui/kbd/kbd.tsx +9 -0
  122. package/src/ui/kbd/types.ts +21 -0
  123. 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,4 @@
1
+ "use client";
2
+ export { AnimatedNumber, AnimatedNumberCounter } from "./animated-number";
3
+ export type { AnimatedNumberProps, AnimatedNumberCounterProps } from "./types";
4
+ export { animatedNumberAppearance } from "./variants";
@@ -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,10 @@
1
+ "use client";
2
+
3
+ export { CopyButton } from "./copy-button";
4
+ export type {
5
+ CopyButtonBaseProps,
6
+ CopyButtonIconRenderer,
7
+ CopyButtonProps,
8
+ CopyButtonVariantProps,
9
+ } from "./types";
10
+ export { copyButtonVariants } from "./variants";
@@ -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,9 @@
1
+ "use client";
2
+
3
+ export { KbdAnimated } from "./kbd-animated";
4
+ export { kbdAnimationPresets } from "./animations";
5
+ export type {
6
+ KbdAnimatedProps,
7
+ KbdAnimation,
8
+ KbdAnimationPresets,
9
+ } from "./types";
@@ -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>;
@@ -0,0 +1,5 @@
1
+ "use client";
2
+
3
+ export { Kbd } from "./kbd";
4
+ export type { KbdBaseProps, KbdProps, KbdVariantProps } from "./types";
5
+ export { kbdKeyVariants, kbdSeparatorVariants } from "./variants";