1'use client';
2
3import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
4import * as React from 'react';
5import { cn } from '../lib/cn';
6
7type IconProps = React.SVGProps<SVGSVGElement> & {
8 size?: number | string;
9};
10
11function CheckCircleIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {
12 return (
13 <svg
14 xmlns="http://www.w3.org/2000/svg"
15 width={size}
16 height={size}
17 viewBox="0 0 24 24"
18 fill="none"
19 stroke="currentColor"
20 strokeWidth={strokeWidth}
21 strokeLinecap="round"
22 strokeLinejoin="round"
23 aria-hidden="true"
24 {...props}
25 >
26 <circle cx="12" cy="12" r="9.5" />
27 <path d="m8 12 2.7 2.7L16 9.3" />
28 </svg>
29 );
30}
31
32function CopyIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {
33 return (
34 <svg
35 xmlns="http://www.w3.org/2000/svg"
36 width={size}
37 height={size}
38 viewBox="0 0 24 24"
39 fill="none"
40 stroke="currentColor"
41 strokeWidth={strokeWidth}
42 strokeLinecap="round"
43 strokeLinejoin="round"
44 aria-hidden="true"
45 {...props}
46 >
47 <rect x="8.5" y="8.5" width="12.5" height="12.5" rx="3" />
48 <path d="M5.5 15.5h-.5a2.5 2.5 0 0 1-2.5-2.5V5.5A2.5 2.5 0 0 1 5 3h7.5A2.5 2.5 0 0 1 15 5.5V6" />
49 </svg>
50 );
51}
52
53const ICON_VARIANTS = {
54 initial: { opacity: 0, scale: 0.6, rotate: -10 },
55 animate: { opacity: 1, scale: 1, rotate: 0 },
56 exit: { opacity: 0, scale: 0.6, rotate: 10 },
57} as const;
58
59const ICON_TRANSITION = { duration: 0.18, ease: 'easeOut' } as const;
60const REDUCED_MOTION_PROPS = {
61 initial: { opacity: 0 },
62 animate: { opacity: 1 },
63 exit: { opacity: 0 },
64 transition: { duration: 0.12 },
65} as const;
66
67export interface CommandLabels {
68 copy: string;
69 copyHint: string;
70 copied: string;
71}
72
73const DEFAULT_LABELS: CommandLabels = {
74 copy: 'Copy command',
75 copyHint: 'Copy to clipboard',
76 copied: 'Copied!',
77};
78
79export interface CommandProps extends React.HTMLAttributes<HTMLDivElement> {
80 command: string;
81 prefix?: string;
82 resetDelay?: number;
83 onCopied?: () => void;
84 labels?: Partial<CommandLabels>;
85}
86
87export const Command = ({
88 command,
89 prefix = '$',
90 resetDelay = 2000,
91 onCopied,
92 labels: labelOverrides,
93 className,
94 ref,
95 ...props
96}: CommandProps & { ref?: React.Ref<HTMLDivElement> }) => {
97 const labels = { ...DEFAULT_LABELS, ...labelOverrides };
98 const [copied, setCopied] = React.useState(false);
99 const shouldReduceMotion = useReducedMotion();
100 const resetTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
101
102 React.useEffect(() => {
103 return () => {
104 if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
105 };
106 }, []);
107
108 const markCopied = React.useCallback(() => {
109 setCopied(true);
110 onCopied?.();
111 if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
112 resetTimeoutRef.current = setTimeout(() => setCopied(false), resetDelay);
113 }, [onCopied, resetDelay]);
114
115 const handleCopy = React.useCallback(async () => {
116 if (copied) return;
117 try {
118 await navigator.clipboard.writeText(command);
119 markCopied();
120 } catch {
121 const previousFocus =
122 document.activeElement instanceof HTMLElement ? document.activeElement : null;
123 const textarea = document.createElement('textarea');
124 textarea.value = command;
125 textarea.setAttribute('readonly', '');
126 textarea.style.position = 'fixed';
127 textarea.style.opacity = '0';
128 document.body.appendChild(textarea);
129 textarea.focus();
130 textarea.select();
131 let succeeded: boolean;
132 try {
133 succeeded = document.execCommand('copy');
134 } catch {
135 succeeded = false;
136 } finally {
137 document.body.removeChild(textarea);
138 previousFocus?.focus();
139 }
140 if (succeeded) markCopied();
141 }
142 }, [command, copied, markCopied]);
143
144 return (
145 <div
146 ref={ref}
147 className={cn(
148 'text-foreground bg-muted/30 border-border flex w-full items-center gap-3 rounded-md border px-4 py-2.5 font-mono text-sm',
149 className,
150 )}
151 {...props}
152 >
153 <span className="text-muted-foreground shrink-0 select-none" aria-hidden="true">
154 {prefix}
155 </span>
156
157 <code className="min-w-0 flex-1 overflow-x-auto text-sm whitespace-nowrap select-all">
158 {command}
159 </code>
160
161 <button
162 type="button"
163 onClick={handleCopy}
164 aria-label={labels.copy}
165 title={copied ? labels.copied : labels.copyHint}
166 className={cn(
167 'text-muted-foreground hover:text-foreground -m-1.5 shrink-0 rounded p-2 transition-colors',
168 'focus-visible:ring-ring ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:outline-none',
169 copied && 'text-success hover:text-success',
170 )}
171 >
172 <AnimatePresence mode="wait" initial={false}>
173 {copied ? (
174 <motion.span
175 key="check"
176 initial={shouldReduceMotion ? REDUCED_MOTION_PROPS.initial : ICON_VARIANTS.initial}
177 animate={shouldReduceMotion ? REDUCED_MOTION_PROPS.animate : ICON_VARIANTS.animate}
178 exit={shouldReduceMotion ? REDUCED_MOTION_PROPS.exit : ICON_VARIANTS.exit}
179 transition={shouldReduceMotion ? REDUCED_MOTION_PROPS.transition : ICON_TRANSITION}
180 className="flex"
181 aria-hidden="true"
182 >
183 <CheckCircleIcon size={16} className="size-4" />
184 </motion.span>
185 ) : (
186 <motion.span
187 key="copy"
188 initial={shouldReduceMotion ? REDUCED_MOTION_PROPS.initial : ICON_VARIANTS.initial}
189 animate={shouldReduceMotion ? REDUCED_MOTION_PROPS.animate : ICON_VARIANTS.animate}
190 exit={shouldReduceMotion ? REDUCED_MOTION_PROPS.exit : ICON_VARIANTS.exit}
191 transition={shouldReduceMotion ? REDUCED_MOTION_PROPS.transition : ICON_TRANSITION}
192 className="flex"
193 aria-hidden="true"
194 >
195 <CopyIcon size={16} className="size-4" />
196 </motion.span>
197 )}
198 </AnimatePresence>
199 </button>
200 <span role="status" aria-live="polite" className="sr-only">
201 {copied ? labels.copied : ''}
202 </span>
203 </div>
204 );
205};