Skip to content

Commit 7aa79b1

Browse files
committed
feat(colors): improve copy controls and accurate hex display
1 parent 8651464 commit 7aa79b1

1 file changed

Lines changed: 243 additions & 21 deletions

File tree

src/components/colors-page.tsx

Lines changed: 243 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as React from "react";
2+
import { Check, Copy } from "lucide-react";
23
import {
34
Table,
45
TableBody,
@@ -56,7 +57,143 @@ type Swatch = {
5657
needsBorder?: boolean;
5758
};
5859

59-
function SwatchCard({ swatch }: { swatch: Swatch }) {
60+
type SwatchCopyMode = "hex-only" | "class-and-hex";
61+
62+
let colorContext: CanvasRenderingContext2D | null = null;
63+
64+
function getColorContext() {
65+
if (typeof document === "undefined") {
66+
return null;
67+
}
68+
69+
if (!colorContext) {
70+
const canvas = document.createElement("canvas");
71+
canvas.width = 1;
72+
canvas.height = 1;
73+
colorContext = canvas.getContext("2d");
74+
}
75+
76+
return colorContext;
77+
}
78+
79+
function toHex(color: string) {
80+
const ctx = getColorContext();
81+
if (!ctx) {
82+
return "";
83+
}
84+
85+
try {
86+
ctx.clearRect(0, 0, 1, 1);
87+
ctx.fillStyle = "rgba(0,0,0,0)";
88+
ctx.fillStyle = color;
89+
ctx.fillRect(0, 0, 1, 1);
90+
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
91+
if (a < 255) {
92+
return `#${[r, g, b, a].map((n) => n.toString(16).padStart(2, "0")).join("").toUpperCase()}`;
93+
}
94+
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("").toUpperCase()}`;
95+
} catch {
96+
return "";
97+
}
98+
}
99+
100+
function resolveCssColorHex(value: string) {
101+
if (typeof window === "undefined") {
102+
return "";
103+
}
104+
105+
const probe = document.createElement("span");
106+
probe.style.color = value;
107+
probe.style.position = "absolute";
108+
probe.style.opacity = "0";
109+
probe.style.pointerEvents = "none";
110+
document.body.appendChild(probe);
111+
const computed = getComputedStyle(probe).color;
112+
document.body.removeChild(probe);
113+
return toHex(computed);
114+
}
115+
116+
function resolveTokenHex(token: string, dark = false) {
117+
if (typeof window === "undefined") {
118+
return "";
119+
}
120+
121+
const mount = document.createElement("div");
122+
mount.className = dark ? "dark" : "";
123+
mount.style.position = "absolute";
124+
mount.style.opacity = "0";
125+
mount.style.pointerEvents = "none";
126+
127+
const probe = document.createElement("span");
128+
probe.style.color = `var(--${token})`;
129+
mount.appendChild(probe);
130+
document.body.appendChild(mount);
131+
132+
const computed = getComputedStyle(probe).color;
133+
document.body.removeChild(mount);
134+
return toHex(computed);
135+
}
136+
137+
async function copyText(value: string) {
138+
if (!value) {
139+
return;
140+
}
141+
142+
try {
143+
await navigator.clipboard.writeText(value);
144+
} catch {
145+
const ta = document.createElement("textarea");
146+
ta.value = value;
147+
ta.style.position = "fixed";
148+
ta.style.left = "-9999px";
149+
document.body.appendChild(ta);
150+
ta.focus();
151+
ta.select();
152+
document.execCommand("copy");
153+
document.body.removeChild(ta);
154+
}
155+
}
156+
157+
function SwatchCard({
158+
swatch,
159+
copyMode,
160+
}: {
161+
swatch: Swatch;
162+
copyMode: SwatchCopyMode;
163+
}) {
164+
const [lightHex, setLightHex] = React.useState("");
165+
const [darkHex, setDarkHex] = React.useState("");
166+
const [copied, setCopied] = React.useState<"light" | "dark" | "class" | "">("");
167+
168+
React.useEffect(() => {
169+
setLightHex(resolveTokenHex(swatch.token, false));
170+
setDarkHex(resolveTokenHex(swatch.token, true));
171+
}, [swatch.token]);
172+
173+
React.useEffect(() => {
174+
if (!copied) {
175+
return;
176+
}
177+
178+
const id = window.setTimeout(() => setCopied(""), 1200);
179+
return () => window.clearTimeout(id);
180+
}, [copied]);
181+
182+
const onCopyLightHex = async () => {
183+
await copyText(lightHex);
184+
setCopied("light");
185+
};
186+
187+
const onCopyDarkHex = async () => {
188+
await copyText(darkHex);
189+
setCopied("dark");
190+
};
191+
192+
const onCopyClass = async () => {
193+
await copyText(swatch.className);
194+
setCopied("class");
195+
};
196+
60197
return (
61198
<div className="border-border bg-card flex flex-col overflow-hidden rounded-lg border">
62199
<div
@@ -66,14 +203,55 @@ function SwatchCard({ swatch }: { swatch: Swatch }) {
66203
>
67204
Aa
68205
</div>
69-
<div className="space-y-1 p-4">
70-
<div className="text-foreground text-sm font-semibold">
206+
<div className="space-y-2 p-4">
207+
<div className="text-foreground text-sm font-semibold leading-none">
71208
{swatch.token}
72209
</div>
73-
<code className="text-muted-foreground block font-mono text-xs">
74-
{swatch.className}
75-
</code>
76-
<div className="text-muted-foreground pt-1 text-xs">
210+
{copyMode === "class-and-hex" ? (
211+
<button
212+
type="button"
213+
onClick={onCopyClass}
214+
aria-label="Copy class name"
215+
title={`Copy ${swatch.className}`}
216+
className="border-border text-muted-foreground hover:bg-muted focus-visible:ring-ring/50 inline-flex h-7 items-center gap-1.5 self-start rounded-md border px-2 font-mono text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
217+
>
218+
<span className="tabular-nums">{swatch.className}</span>
219+
{copied === "class" ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
220+
</button>
221+
) : (
222+
<code className="text-muted-foreground block font-mono text-xs">
223+
{swatch.className}
224+
</code>
225+
)}
226+
{lightHex || darkHex ? (
227+
<div className="font-mono text-xs">
228+
<div className="flex flex-wrap items-center gap-2">
229+
<button
230+
type="button"
231+
onClick={onCopyLightHex}
232+
aria-label="Copy light hex"
233+
title={`Copy light ${lightHex || "—"}`}
234+
className="border-border text-muted-foreground hover:bg-muted focus-visible:ring-ring/50 inline-flex h-7 items-center gap-1.5 rounded-md border px-2 tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none"
235+
>
236+
<span className="font-mono">L</span>
237+
<span>{lightHex || "—"}</span>
238+
{copied === "light" ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
239+
</button>
240+
<button
241+
type="button"
242+
onClick={onCopyDarkHex}
243+
aria-label="Copy dark hex"
244+
title={`Copy dark ${darkHex || "—"}`}
245+
className="border-border text-muted-foreground hover:bg-muted focus-visible:ring-ring/50 inline-flex h-7 items-center gap-1.5 rounded-md border px-2 tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none"
246+
>
247+
<span className="font-mono">D</span>
248+
<span>{darkHex || "—"}</span>
249+
{copied === "dark" ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
250+
</button>
251+
</div>
252+
</div>
253+
) : null}
254+
<div className="text-muted-foreground text-xs">
77255
<span className="font-mono">L</span> {swatch.light}{" "}
78256
<span className="text-foreground/30">·</span>{" "}
79257
<span className="font-mono">D</span> {swatch.dark}
@@ -84,11 +262,17 @@ function SwatchCard({ swatch }: { swatch: Swatch }) {
84262
);
85263
}
86264

87-
function SwatchGrid({ items }: { items: Swatch[] }) {
265+
function SwatchGrid({
266+
items,
267+
copyMode = "class-and-hex",
268+
}: {
269+
items: Swatch[];
270+
copyMode?: SwatchCopyMode;
271+
}) {
88272
return (
89273
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
90274
{items.map((s) => (
91-
<SwatchCard key={s.token} swatch={s} />
275+
<SwatchCard key={s.token} swatch={s} copyMode={copyMode} />
92276
))}
93277
</div>
94278
);
@@ -630,6 +814,45 @@ const RAMP_STEPS = [
630814
"950",
631815
];
632816

817+
function PaletteBox({
818+
className,
819+
label,
820+
textClassName,
821+
heightClassName = "h-14",
822+
}: {
823+
className: string;
824+
label: string;
825+
textClassName: string;
826+
heightClassName?: string;
827+
}) {
828+
const [copied, setCopied] = React.useState(false);
829+
830+
React.useEffect(() => {
831+
if (!copied) {
832+
return;
833+
}
834+
835+
const id = window.setTimeout(() => setCopied(false), 1000);
836+
return () => window.clearTimeout(id);
837+
}, [copied]);
838+
839+
const onCopyClass = async () => {
840+
await copyText(className);
841+
setCopied(true);
842+
};
843+
844+
return (
845+
<button
846+
type="button"
847+
onClick={onCopyClass}
848+
title={`Copy ${className}`}
849+
className={`${className} ${textClassName} ${heightClassName} flex flex-1 cursor-copy items-end justify-center pb-1 font-mono text-[10px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50`}
850+
>
851+
{copied ? "Copied" : label}
852+
</button>
853+
);
854+
}
855+
633856
function ColorRamp({ ramp }: { ramp: RampDef }) {
634857
return (
635858
<div className="border-border overflow-hidden rounded-lg border">
@@ -641,14 +864,12 @@ function ColorRamp({ ramp }: { ramp: RampDef }) {
641864
</div>
642865
<div className="flex">
643866
{ramp.bg.map((cls, i) => (
644-
<div
867+
<PaletteBox
645868
key={cls}
646-
className={`${cls} ${
647-
i >= 5 ? "text-white" : "text-neutral-950"
648-
} flex h-14 flex-1 items-end justify-center pb-1 font-mono text-[10px]`}
649-
>
650-
{RAMP_STEPS[i]}
651-
</div>
869+
className={cls}
870+
textClassName={i >= 5 ? "text-white" : "text-neutral-950"}
871+
label={RAMP_STEPS[i]}
872+
/>
652873
))}
653874
</div>
654875
</div>
@@ -980,12 +1201,13 @@ export function ColorsPage() {
9801201
<div className="border-border mt-6 overflow-hidden rounded-lg border">
9811202
<div className="flex">
9821203
{PRIMARY_SCALE.map((s) => (
983-
<div
1204+
<PaletteBox
9841205
key={s.step}
985-
className={`${s.className} ${s.onClass} flex h-20 flex-1 items-end justify-center pb-2 font-mono text-[11px]`}
986-
>
987-
{s.step}
988-
</div>
1206+
className={s.className}
1207+
textClassName={s.onClass}
1208+
label={s.step}
1209+
heightClassName="h-20"
1210+
/>
9891211
))}
9901212
</div>
9911213
<div className="bg-card border-border border-t p-4">

0 commit comments

Comments
 (0)