@stasho/ds 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/package.json +88 -0
- package/src/components/alert/alert.tsx +204 -0
- package/src/components/badge/badge.tsx +139 -0
- package/src/components/breadcrumb/breadcrumb.tsx +130 -0
- package/src/components/button/button.tsx +153 -0
- package/src/components/card/card.tsx +48 -0
- package/src/components/checkbox/checkbox.tsx +82 -0
- package/src/components/combobox/combobox.tsx +166 -0
- package/src/components/copyable-text/copyable-text.tsx +194 -0
- package/src/components/dialog/dialog.tsx +145 -0
- package/src/components/form-field/form-field.tsx +71 -0
- package/src/components/input/input.tsx +52 -0
- package/src/components/logo/logo.tsx +68 -0
- package/src/components/multi-select/multi-select.tsx +312 -0
- package/src/components/pagination/pagination.tsx +218 -0
- package/src/components/progress-bar/progress-bar.tsx +134 -0
- package/src/components/radio-group/radio-group.tsx +87 -0
- package/src/components/select/select.tsx +124 -0
- package/src/components/slider/slider.tsx +127 -0
- package/src/components/status-dot/status-dot.tsx +53 -0
- package/src/components/stepper/stepper.tsx +229 -0
- package/src/components/switch/switch.tsx +74 -0
- package/src/components/table/table.tsx +260 -0
- package/src/components/tabs/tabs.tsx +493 -0
- package/src/components/textarea/textarea.tsx +56 -0
- package/src/components/tooltip/tooltip.tsx +35 -0
- package/src/components/ui/skeleton.tsx +21 -0
- package/src/components/ui/spinner.tsx +27 -0
- package/src/lib/cn.ts +6 -0
- package/src/styles/tokens.css +459 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cloneElement,
|
|
3
|
+
forwardRef,
|
|
4
|
+
isValidElement,
|
|
5
|
+
type ButtonHTMLAttributes,
|
|
6
|
+
type ReactElement,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
10
|
+
import { cn } from "@ac/lib/cn";
|
|
11
|
+
import { Spinner } from "@ac/components/ui/spinner";
|
|
12
|
+
|
|
13
|
+
const buttonVariants = cva(
|
|
14
|
+
[
|
|
15
|
+
"inline-flex items-center justify-center",
|
|
16
|
+
"font-heading font-bold",
|
|
17
|
+
"rounded-full border-3 transition-colors",
|
|
18
|
+
"focus-visible:outline-none focus-visible:ring-2",
|
|
19
|
+
"focus-visible:ring-primary-400 focus-visible:ring-offset-2",
|
|
20
|
+
"disabled:pointer-events-none",
|
|
21
|
+
].join(" "),
|
|
22
|
+
{
|
|
23
|
+
variants: {
|
|
24
|
+
variant: {
|
|
25
|
+
primary: [
|
|
26
|
+
"gradient-fill-main text-white border-transparent",
|
|
27
|
+
"disabled:opacity-50",
|
|
28
|
+
].join(" "),
|
|
29
|
+
secondary: [
|
|
30
|
+
"gradient-fill-lime text-neutral-950 border-neutral-950",
|
|
31
|
+
"disabled:opacity-50",
|
|
32
|
+
].join(" "),
|
|
33
|
+
outline: [
|
|
34
|
+
"border-gradient-main text-primary-700",
|
|
35
|
+
"hover:text-primary-800",
|
|
36
|
+
"active:text-primary-800",
|
|
37
|
+
"disabled:opacity-50",
|
|
38
|
+
].join(" "),
|
|
39
|
+
text: [
|
|
40
|
+
"bg-transparent text-primary-600 dark:text-primary-300 border-transparent",
|
|
41
|
+
"hover:bg-primary-100 hover:text-primary-700",
|
|
42
|
+
"dark:hover:bg-primary-200/10 dark:hover:text-primary-200",
|
|
43
|
+
"active:bg-primary-200 active:text-primary-800",
|
|
44
|
+
"dark:active:bg-primary-700 dark:active:text-primary-100",
|
|
45
|
+
"disabled:bg-transparent disabled:text-primary-600/50",
|
|
46
|
+
"dark:disabled:text-primary-300/50",
|
|
47
|
+
].join(" "),
|
|
48
|
+
destructive: [
|
|
49
|
+
"bg-error-600/20 text-error-700 dark:text-error-300 border-error-600",
|
|
50
|
+
"hover:bg-error-600/30 hover:border-error-700",
|
|
51
|
+
"active:bg-error-600/40 active:border-error-800",
|
|
52
|
+
"disabled:bg-error-600/10 disabled:text-error-700/50 disabled:border-error-600/50",
|
|
53
|
+
].join(" "),
|
|
54
|
+
warning: [
|
|
55
|
+
"bg-warning-500/20 text-warning-800 dark:text-warning-200 border-warning-500",
|
|
56
|
+
"hover:bg-warning-500/30 hover:border-warning-600",
|
|
57
|
+
"active:bg-warning-500/40 active:border-warning-700",
|
|
58
|
+
"disabled:bg-warning-500/10 disabled:text-warning-800/50 disabled:border-warning-500/50",
|
|
59
|
+
].join(" "),
|
|
60
|
+
},
|
|
61
|
+
size: {
|
|
62
|
+
xs: "py-1 px-4 text-sm gap-1",
|
|
63
|
+
sm: "py-1.5 px-5 text-base gap-1.5",
|
|
64
|
+
md: "py-2 px-6 text-base gap-2",
|
|
65
|
+
lg: "py-2.5 px-8 text-lg gap-2",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
defaultVariants: {
|
|
69
|
+
variant: "primary",
|
|
70
|
+
size: "md",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const iconSize = {
|
|
76
|
+
xs: "size-3.5",
|
|
77
|
+
sm: "size-4",
|
|
78
|
+
md: "size-4",
|
|
79
|
+
lg: "size-5",
|
|
80
|
+
} as const;
|
|
81
|
+
|
|
82
|
+
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
|
83
|
+
VariantProps<typeof buttonVariants> & {
|
|
84
|
+
iconLeft?: ReactNode;
|
|
85
|
+
iconRight?: ReactNode;
|
|
86
|
+
loading?: boolean;
|
|
87
|
+
asChild?: boolean;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
91
|
+
(
|
|
92
|
+
{
|
|
93
|
+
variant,
|
|
94
|
+
size,
|
|
95
|
+
iconLeft,
|
|
96
|
+
iconRight,
|
|
97
|
+
loading = false,
|
|
98
|
+
disabled = false,
|
|
99
|
+
asChild = false,
|
|
100
|
+
className,
|
|
101
|
+
children,
|
|
102
|
+
...rest
|
|
103
|
+
},
|
|
104
|
+
ref,
|
|
105
|
+
) => {
|
|
106
|
+
const sizeKey = size ?? "md";
|
|
107
|
+
const classes = cn(
|
|
108
|
+
buttonVariants({ variant, size }),
|
|
109
|
+
loading && "pointer-events-none",
|
|
110
|
+
className,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const iconClass = cn("shrink-0", iconSize[sizeKey], "[&>svg]:size-full");
|
|
114
|
+
|
|
115
|
+
const content = (
|
|
116
|
+
<>
|
|
117
|
+
{loading ? (
|
|
118
|
+
<Spinner className={cn("shrink-0", iconSize[sizeKey])} />
|
|
119
|
+
) : iconLeft ? (
|
|
120
|
+
<span className={iconClass}>{iconLeft}</span>
|
|
121
|
+
) : null}
|
|
122
|
+
<span>{children}</span>
|
|
123
|
+
{!loading && iconRight ? (
|
|
124
|
+
<span className={iconClass}>{iconRight}</span>
|
|
125
|
+
) : null}
|
|
126
|
+
</>
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
if (asChild && isValidElement(children)) {
|
|
130
|
+
return cloneElement(children as ReactElement<Record<string, unknown>>, {
|
|
131
|
+
className: classes,
|
|
132
|
+
ref,
|
|
133
|
+
...rest,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<button
|
|
139
|
+
ref={ref}
|
|
140
|
+
className={classes}
|
|
141
|
+
disabled={disabled}
|
|
142
|
+
aria-busy={loading || undefined}
|
|
143
|
+
{...rest}
|
|
144
|
+
>
|
|
145
|
+
{content}
|
|
146
|
+
</button>
|
|
147
|
+
);
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
Button.displayName = "Button";
|
|
152
|
+
|
|
153
|
+
export { Button, buttonVariants, type ButtonProps };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { cn } from "@ac/lib/cn";
|
|
4
|
+
|
|
5
|
+
const cardVariants = cva("rounded-md", {
|
|
6
|
+
variants: {
|
|
7
|
+
variant: {
|
|
8
|
+
default: "bg-surface text-surface-foreground",
|
|
9
|
+
noise: "fx-grain-lg text-surface-foreground",
|
|
10
|
+
ghost: "bg-transparent",
|
|
11
|
+
},
|
|
12
|
+
padding: {
|
|
13
|
+
sm: "p-4",
|
|
14
|
+
md: "p-6",
|
|
15
|
+
lg: "p-8",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
defaultVariants: {
|
|
19
|
+
variant: "default",
|
|
20
|
+
padding: "md",
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
type CardProps = HTMLAttributes<HTMLDivElement> &
|
|
25
|
+
VariantProps<typeof cardVariants> & {
|
|
26
|
+
title?: ReactNode;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const Card = forwardRef<HTMLDivElement, CardProps>(
|
|
30
|
+
({ variant, padding, title, className, children, ...rest }, ref) => {
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
ref={ref}
|
|
34
|
+
className={cn(cardVariants({ variant, padding }), className)}
|
|
35
|
+
{...rest}
|
|
36
|
+
>
|
|
37
|
+
{title ? (
|
|
38
|
+
<h3 className="mb-4 text-lg font-heading font-bold">{title}</h3>
|
|
39
|
+
) : null}
|
|
40
|
+
{children}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
Card.displayName = "Card";
|
|
47
|
+
|
|
48
|
+
export { Card, cardVariants, type CardProps };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { forwardRef, type ComponentPropsWithoutRef } from "react";
|
|
2
|
+
import { Checkbox as CheckboxPrimitive } from "radix-ui";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import { cn } from "@ac/lib/cn";
|
|
5
|
+
|
|
6
|
+
const checkboxVariants = cva(
|
|
7
|
+
[
|
|
8
|
+
"peer shrink-0 bg-surface",
|
|
9
|
+
"border-3 border-edge",
|
|
10
|
+
"hover:border-edge-hover",
|
|
11
|
+
"focus-visible:outline-none focus-visible:ring-3",
|
|
12
|
+
"focus-visible:ring-primary-500",
|
|
13
|
+
"disabled:opacity-50 disabled:pointer-events-none",
|
|
14
|
+
"data-[state=checked]:bg-primary data-[state=checked]:border-primary",
|
|
15
|
+
"data-[state=checked]:text-primary-foreground",
|
|
16
|
+
"transition-colors",
|
|
17
|
+
].join(" "),
|
|
18
|
+
{
|
|
19
|
+
variants: {
|
|
20
|
+
size: {
|
|
21
|
+
xs: "size-4 rounded",
|
|
22
|
+
sm: "size-5 rounded-md",
|
|
23
|
+
md: "size-6 rounded-md",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
defaultVariants: {
|
|
27
|
+
size: "md",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
type CheckboxProps = Omit<
|
|
33
|
+
ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
|
|
34
|
+
"size"
|
|
35
|
+
> &
|
|
36
|
+
VariantProps<typeof checkboxVariants> & {
|
|
37
|
+
error?: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
|
|
41
|
+
({ size, error = false, className, ...rest }, ref) => {
|
|
42
|
+
return (
|
|
43
|
+
<CheckboxPrimitive.Root
|
|
44
|
+
ref={ref}
|
|
45
|
+
className={cn(
|
|
46
|
+
checkboxVariants({ size }),
|
|
47
|
+
error && "border-3 border-error-400 hover:border-error-500",
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
aria-invalid={error || undefined}
|
|
51
|
+
{...rest}
|
|
52
|
+
>
|
|
53
|
+
<CheckboxPrimitive.Indicator
|
|
54
|
+
forceMount
|
|
55
|
+
className={cn(
|
|
56
|
+
"flex size-full items-center justify-center text-current",
|
|
57
|
+
"[clip-path:circle(0%_at_0%_75%)]",
|
|
58
|
+
"data-[state=checked]:[clip-path:circle(100%_at_50%_50%)]",
|
|
59
|
+
"transition-[clip-path] duration-200 ease-in-out motion-reduce:transition-none",
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
<svg
|
|
63
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
64
|
+
viewBox="0 0 24 24"
|
|
65
|
+
fill="none"
|
|
66
|
+
stroke="currentColor"
|
|
67
|
+
strokeWidth={3}
|
|
68
|
+
strokeLinecap="round"
|
|
69
|
+
strokeLinejoin="round"
|
|
70
|
+
className="size-[90%]"
|
|
71
|
+
>
|
|
72
|
+
<polyline points="20 6 9 17 4 12" />
|
|
73
|
+
</svg>
|
|
74
|
+
</CheckboxPrimitive.Indicator>
|
|
75
|
+
</CheckboxPrimitive.Root>
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
Checkbox.displayName = "Checkbox";
|
|
81
|
+
|
|
82
|
+
export { Checkbox, checkboxVariants, type CheckboxProps };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { forwardRef, useState } from "react";
|
|
2
|
+
import { Popover } from "radix-ui";
|
|
3
|
+
import { Command } from "cmdk";
|
|
4
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
5
|
+
import { CaretDown, Check } from "@phosphor-icons/react";
|
|
6
|
+
import { cn } from "@ac/lib/cn";
|
|
7
|
+
|
|
8
|
+
const triggerVariants = cva(
|
|
9
|
+
[
|
|
10
|
+
"inline-flex items-center justify-between",
|
|
11
|
+
"w-full font-sans text-foreground bg-primary-100 dark:bg-base-700",
|
|
12
|
+
"border-0 rounded-full",
|
|
13
|
+
"focus-visible:outline-none focus-visible:ring-3",
|
|
14
|
+
"focus-visible:ring-primary-500",
|
|
15
|
+
"disabled:opacity-50 disabled:pointer-events-none",
|
|
16
|
+
"ring-0 transition-colors",
|
|
17
|
+
].join(" "),
|
|
18
|
+
{
|
|
19
|
+
variants: {
|
|
20
|
+
size: {
|
|
21
|
+
sm: "py-1.5 px-4 text-sm",
|
|
22
|
+
md: "py-2 px-5 text-base",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: {
|
|
26
|
+
size: "md",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
type ComboboxOption = {
|
|
32
|
+
value: string;
|
|
33
|
+
label: string;
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type ComboboxProps = VariantProps<typeof triggerVariants> & {
|
|
38
|
+
options: ComboboxOption[];
|
|
39
|
+
value?: string;
|
|
40
|
+
onValueChange?: (value: string) => void;
|
|
41
|
+
placeholder?: string;
|
|
42
|
+
searchPlaceholder?: string;
|
|
43
|
+
emptyMessage?: string;
|
|
44
|
+
size?: "sm" | "md";
|
|
45
|
+
error?: boolean;
|
|
46
|
+
disabled?: boolean;
|
|
47
|
+
className?: string;
|
|
48
|
+
id?: string;
|
|
49
|
+
"aria-describedby"?: string;
|
|
50
|
+
"aria-invalid"?: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(
|
|
54
|
+
(
|
|
55
|
+
{
|
|
56
|
+
options,
|
|
57
|
+
value,
|
|
58
|
+
onValueChange,
|
|
59
|
+
placeholder = "Select...",
|
|
60
|
+
searchPlaceholder = "Search...",
|
|
61
|
+
emptyMessage = "No results found.",
|
|
62
|
+
size,
|
|
63
|
+
error = false,
|
|
64
|
+
disabled = false,
|
|
65
|
+
className,
|
|
66
|
+
id,
|
|
67
|
+
"aria-describedby": ariaDescribedBy,
|
|
68
|
+
...rest
|
|
69
|
+
},
|
|
70
|
+
ref,
|
|
71
|
+
) => {
|
|
72
|
+
const [open, setOpen] = useState(false);
|
|
73
|
+
const selectedLabel = options.find((o) => o.value === value)?.label;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Popover.Root open={open} onOpenChange={setOpen}>
|
|
77
|
+
<Popover.Trigger
|
|
78
|
+
ref={ref}
|
|
79
|
+
id={id}
|
|
80
|
+
disabled={disabled}
|
|
81
|
+
aria-describedby={ariaDescribedBy}
|
|
82
|
+
aria-invalid={error || undefined}
|
|
83
|
+
className={cn(
|
|
84
|
+
triggerVariants({ size }),
|
|
85
|
+
error && "border-3 border-error-400 hover:border-error-500",
|
|
86
|
+
!selectedLabel && "text-muted-foreground",
|
|
87
|
+
className,
|
|
88
|
+
)}
|
|
89
|
+
{...rest}
|
|
90
|
+
>
|
|
91
|
+
<span className="truncate">
|
|
92
|
+
{selectedLabel ?? placeholder}
|
|
93
|
+
</span>
|
|
94
|
+
<CaretDown
|
|
95
|
+
weight="bold"
|
|
96
|
+
className={cn(
|
|
97
|
+
"ml-2 size-4 shrink-0 text-muted-foreground",
|
|
98
|
+
"transition-transform motion-reduce:transition-none",
|
|
99
|
+
open && "rotate-180",
|
|
100
|
+
)}
|
|
101
|
+
aria-hidden="true"
|
|
102
|
+
/>
|
|
103
|
+
</Popover.Trigger>
|
|
104
|
+
<Popover.Portal>
|
|
105
|
+
<Popover.Content
|
|
106
|
+
className={cn(
|
|
107
|
+
"z-50 w-[var(--radix-popover-trigger-width)]",
|
|
108
|
+
"overflow-hidden rounded-2xl",
|
|
109
|
+
"bg-surface border border-edge shadow-brand",
|
|
110
|
+
)}
|
|
111
|
+
sideOffset={4}
|
|
112
|
+
align="start"
|
|
113
|
+
>
|
|
114
|
+
<Command>
|
|
115
|
+
<Command.Input
|
|
116
|
+
placeholder={searchPlaceholder}
|
|
117
|
+
className={cn(
|
|
118
|
+
"w-full border-b border-edge bg-transparent px-4 py-2.5",
|
|
119
|
+
"text-sm text-foreground placeholder:text-muted-foreground",
|
|
120
|
+
"outline-none",
|
|
121
|
+
)}
|
|
122
|
+
/>
|
|
123
|
+
<Command.List className="max-h-60 overflow-y-auto p-1">
|
|
124
|
+
<Command.Empty className="px-4 py-6 text-center text-sm text-muted-foreground">
|
|
125
|
+
{emptyMessage}
|
|
126
|
+
</Command.Empty>
|
|
127
|
+
{options.map((option) => (
|
|
128
|
+
<Command.Item
|
|
129
|
+
key={option.value}
|
|
130
|
+
value={option.label}
|
|
131
|
+
{...(option.disabled ? { disabled: true } : {})}
|
|
132
|
+
onSelect={() => {
|
|
133
|
+
onValueChange?.(option.value);
|
|
134
|
+
setOpen(false);
|
|
135
|
+
}}
|
|
136
|
+
className={cn(
|
|
137
|
+
"relative flex items-center rounded-xl px-4 py-2",
|
|
138
|
+
"text-sm text-foreground cursor-pointer select-none",
|
|
139
|
+
"outline-none",
|
|
140
|
+
"data-[selected=true]:bg-muted",
|
|
141
|
+
"data-[disabled=true]:opacity-50",
|
|
142
|
+
"data-[disabled=true]:pointer-events-none",
|
|
143
|
+
)}
|
|
144
|
+
>
|
|
145
|
+
<span className="flex-1">{option.label}</span>
|
|
146
|
+
{value === option.value && (
|
|
147
|
+
<Check
|
|
148
|
+
weight="bold"
|
|
149
|
+
className="ml-auto size-4"
|
|
150
|
+
aria-hidden="true"
|
|
151
|
+
/>
|
|
152
|
+
)}
|
|
153
|
+
</Command.Item>
|
|
154
|
+
))}
|
|
155
|
+
</Command.List>
|
|
156
|
+
</Command>
|
|
157
|
+
</Popover.Content>
|
|
158
|
+
</Popover.Portal>
|
|
159
|
+
</Popover.Root>
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
Combobox.displayName = "Combobox";
|
|
165
|
+
|
|
166
|
+
export { Combobox, triggerVariants, type ComboboxProps, type ComboboxOption };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type HTMLAttributes,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
12
|
+
import { ArrowUpRight, Copy } from "@phosphor-icons/react";
|
|
13
|
+
import { cn } from "@ac/lib/cn";
|
|
14
|
+
|
|
15
|
+
const copyableTextVariants = cva(
|
|
16
|
+
"inline-flex items-center font-mono select-none",
|
|
17
|
+
{
|
|
18
|
+
variants: {
|
|
19
|
+
size: {
|
|
20
|
+
sm: "text-xs gap-1",
|
|
21
|
+
md: "text-sm gap-1.5",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: {
|
|
25
|
+
size: "md",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const iconSize: Record<"sm" | "md", string> = {
|
|
31
|
+
sm: "size-3.5",
|
|
32
|
+
md: "size-4",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const buttonSize: Record<"sm" | "md", string> = {
|
|
36
|
+
sm: "size-4",
|
|
37
|
+
md: "size-5",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function isExternalUrl(url: string): boolean {
|
|
41
|
+
return /^https?:\/\//.test(url) || url.startsWith("//");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function truncateMiddle(
|
|
45
|
+
text: string,
|
|
46
|
+
startChars: number,
|
|
47
|
+
endChars: number,
|
|
48
|
+
): string {
|
|
49
|
+
if (text.length <= startChars + endChars) return text;
|
|
50
|
+
return `${text.slice(0, startChars)}...${text.slice(-endChars)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type CopyableTextProps = Omit<HTMLAttributes<HTMLSpanElement>, "children"> &
|
|
54
|
+
VariantProps<typeof copyableTextVariants> & {
|
|
55
|
+
text: string;
|
|
56
|
+
startChars?: number;
|
|
57
|
+
endChars?: number;
|
|
58
|
+
href?: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const CopyableText = forwardRef<HTMLSpanElement, CopyableTextProps>(
|
|
62
|
+
(
|
|
63
|
+
{
|
|
64
|
+
text,
|
|
65
|
+
startChars = 6,
|
|
66
|
+
endChars = 4,
|
|
67
|
+
href,
|
|
68
|
+
size = "md",
|
|
69
|
+
className,
|
|
70
|
+
...rest
|
|
71
|
+
},
|
|
72
|
+
ref,
|
|
73
|
+
) => {
|
|
74
|
+
const [copied, setCopied] = useState(false);
|
|
75
|
+
const timerRef = useRef<ReturnType<typeof setTimeout>>(null);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
return () => {
|
|
79
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
80
|
+
};
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const handleCopy = useCallback((e: React.MouseEvent) => {
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
void navigator.clipboard.writeText(text).then(() => {
|
|
86
|
+
setCopied(true);
|
|
87
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
88
|
+
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
|
89
|
+
});
|
|
90
|
+
}, [text]);
|
|
91
|
+
|
|
92
|
+
const resolvedSize = size ?? "md";
|
|
93
|
+
const iconCn = iconSize[resolvedSize];
|
|
94
|
+
const btnCn = buttonSize[resolvedSize];
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<span
|
|
98
|
+
ref={ref}
|
|
99
|
+
className={cn(
|
|
100
|
+
copyableTextVariants({ size }),
|
|
101
|
+
href && "text-primary-500 dark:text-primary-300",
|
|
102
|
+
className,
|
|
103
|
+
)}
|
|
104
|
+
{...rest}
|
|
105
|
+
>
|
|
106
|
+
{href ? (
|
|
107
|
+
<a
|
|
108
|
+
href={href}
|
|
109
|
+
{...(isExternalUrl(href)
|
|
110
|
+
? { target: "_blank", rel: "noopener noreferrer" }
|
|
111
|
+
: {})}
|
|
112
|
+
onClick={(e) => e.stopPropagation()}
|
|
113
|
+
className="cursor-pointer hover:underline"
|
|
114
|
+
>
|
|
115
|
+
{truncateMiddle(text, startChars, endChars)}
|
|
116
|
+
</a>
|
|
117
|
+
) : (
|
|
118
|
+
<span className="cursor-default">
|
|
119
|
+
{truncateMiddle(text, startChars, endChars)}
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={handleCopy}
|
|
126
|
+
className={cn(
|
|
127
|
+
"relative inline-flex items-center justify-center",
|
|
128
|
+
"rounded-md cursor-pointer",
|
|
129
|
+
"hover:bg-foreground/10 transition-colors",
|
|
130
|
+
btnCn,
|
|
131
|
+
)}
|
|
132
|
+
aria-label={copied ? "Copied" : "Copy to clipboard"}
|
|
133
|
+
>
|
|
134
|
+
<Copy
|
|
135
|
+
weight="bold"
|
|
136
|
+
className={cn(
|
|
137
|
+
iconCn,
|
|
138
|
+
"text-muted-foreground",
|
|
139
|
+
"transition-opacity duration-100",
|
|
140
|
+
"motion-reduce:transition-none",
|
|
141
|
+
copied && "opacity-0",
|
|
142
|
+
)}
|
|
143
|
+
aria-hidden="true"
|
|
144
|
+
/>
|
|
145
|
+
<svg
|
|
146
|
+
viewBox="0 0 24 24"
|
|
147
|
+
fill="none"
|
|
148
|
+
stroke="currentColor"
|
|
149
|
+
strokeWidth={3}
|
|
150
|
+
strokeLinecap="round"
|
|
151
|
+
strokeLinejoin="round"
|
|
152
|
+
className={cn(
|
|
153
|
+
iconCn,
|
|
154
|
+
"text-muted-foreground absolute",
|
|
155
|
+
"[stroke-dasharray:20] [stroke-dashoffset:20]",
|
|
156
|
+
"transition-[stroke-dashoffset] duration-300 delay-75 ease-out",
|
|
157
|
+
"motion-reduce:transition-none",
|
|
158
|
+
copied && "[stroke-dashoffset:0]",
|
|
159
|
+
)}
|
|
160
|
+
aria-hidden="true"
|
|
161
|
+
>
|
|
162
|
+
<polyline points="4 12 9 17 20 6" />
|
|
163
|
+
</svg>
|
|
164
|
+
</button>
|
|
165
|
+
|
|
166
|
+
{href && isExternalUrl(href) ? (
|
|
167
|
+
<a
|
|
168
|
+
href={href}
|
|
169
|
+
target="_blank"
|
|
170
|
+
rel="noopener noreferrer"
|
|
171
|
+
onClick={(e) => e.stopPropagation()}
|
|
172
|
+
className={cn(
|
|
173
|
+
"inline-flex items-center justify-center rounded-md",
|
|
174
|
+
"text-muted-foreground hover:text-foreground",
|
|
175
|
+
"hover:bg-foreground/10 transition-colors",
|
|
176
|
+
btnCn,
|
|
177
|
+
)}
|
|
178
|
+
aria-label="Open in new tab"
|
|
179
|
+
>
|
|
180
|
+
<ArrowUpRight
|
|
181
|
+
weight="bold"
|
|
182
|
+
className={iconCn}
|
|
183
|
+
aria-hidden="true"
|
|
184
|
+
/>
|
|
185
|
+
</a>
|
|
186
|
+
) : null}
|
|
187
|
+
</span>
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
CopyableText.displayName = "CopyableText";
|
|
193
|
+
|
|
194
|
+
export { CopyableText, copyableTextVariants, type CopyableTextProps };
|