feat: Add color swatch hover card (#13192)

* feat: Add color swatch hover card

* refactor
This commit is contained in:
Tom Moor
2026-07-29 18:09:17 -04:00
committed by GitHub
parent f8ccdf143c
commit a9d31d3882
5 changed files with 420 additions and 18 deletions
@@ -0,0 +1,85 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import type { ReactNode } from "react";
import styled from "styled-components";
import { depths, s } from "../../styles";
import type { EditorNotice } from "../types";
import { ColorPreview } from "./ColorPreview";
interface Props {
/** The CSS color to preview, in its original notation. */
color: string;
/** Whether the card is currently shown. */
open: boolean;
/** Called when the card is dismissed by Radix, for example on Escape. */
onOpenChange: (open: boolean) => void;
/** Called when the pointer enters the card. */
onMouseEnter: () => void;
/** Called when the pointer leaves the card. */
onMouseLeave: () => void;
/** Callback used to surface a notice to the user. */
onNotice?: EditorNotice;
/** The swatch the card is anchored to. */
children: ReactNode;
}
/**
* The hover card shown alongside a color swatch. It lives in its own module so
* that its browser-only dependency on Radix is loaded lazily and stays out of
* the editor schema graph, which is also imported on the server.
*
* @returns the popover wrapping the provided swatch.
*/
export default function ColorHoverCard({
color,
open,
onOpenChange,
onMouseEnter,
onMouseLeave,
onNotice,
children,
}: Props) {
return (
<PopoverPrimitive.Root open={open} onOpenChange={onOpenChange}>
<PopoverPrimitive.Trigger asChild>{children}</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
asChild
side="top"
align="center"
sideOffset={6}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<Card onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<ColorPreview color={color} onNotice={onNotice} />
</Card>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}
const Card = styled.div`
/* Sized to the widest notation so that no value has to wrap. */
width: max-content;
min-width: 180px;
padding: 8px;
z-index: ${depths.modal};
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 8px;
outline: none;
&[data-state="open"] {
animation: fadeIn 150ms ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`;
+150
View File
@@ -0,0 +1,150 @@
import copy from "copy-to-clipboard";
import { darken } from "polished";
import type { MouseEvent } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "../../styles";
import { toColorFormats } from "../../utils/color";
import type { EditorNotice } from "../types";
interface Props {
/** The CSS color to preview, in its original notation. */
color: string;
/** Callback used to surface a notice to the user. */
onNotice?: EditorNotice;
}
/**
* The contents of the color hover card a large preview of the color above the
* equivalent hex, rgb, and hsl notations. Clicking a notation copies it.
*/
export function ColorPreview({ color, onNotice }: Props) {
const { t } = useTranslation();
const preview = useMemo(() => {
try {
return {
formats: toColorFormats(color),
// A darker shade of the color itself keeps the edge from reading as a
// separate element, whatever the color.
borderColor: darken(0.1, color),
};
} catch {
return undefined;
}
}, [color]);
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
copy(event.currentTarget.value);
onNotice?.(t("Copied to clipboard"));
},
[onNotice, t]
);
if (!preview) {
return null;
}
const { formats, borderColor } = preview;
return (
<>
<Preview style={{ borderColor }}>
<PreviewColor style={{ backgroundColor: color }} />
</Preview>
<Formats>
{(["hex", "rgb", "hsl"] as const).map((format) => (
<Format
key={format}
type="button"
value={formats[format]}
title={t("Click to copy")}
onMouseDown={(event) => event.preventDefault()}
onClick={handleClick}
>
<Label>{format.toUpperCase()}</Label>
<Value>{formats[format]}</Value>
</Format>
))}
</Formats>
</>
);
}
const Preview = styled.div`
height: 64px;
overflow: hidden;
border-radius: 6px;
border: 1px solid transparent;
/* Checkerboard so that translucent colors read as translucent. */
background-color: ${s("background")};
background-image:
linear-gradient(45deg, ${s("inputBorder")} 25%, transparent 25%),
linear-gradient(-45deg, ${s("inputBorder")} 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, ${s("inputBorder")} 75%),
linear-gradient(-45deg, transparent 75%, ${s("inputBorder")} 75%);
background-size: 12px 12px;
background-position:
0 0,
0 6px,
6px -6px,
-6px 0px;
`;
const PreviewColor = styled.div`
width: 100%;
height: 100%;
`;
const Formats = styled.div`
display: flex;
flex-direction: column;
/* The negative bottom margin keeps 8px below the last row of text. */
margin: 4px 0 -4px;
`;
const Label = styled.span`
flex-shrink: 0;
width: 32px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
color: ${s("textTertiary")};
transition: color 100ms ease;
`;
const Value = styled.span`
font-family: ${s("fontFamilyMono")};
font-size: 12px;
color: ${s("textSecondary")};
white-space: nowrap;
user-select: all;
transition: color 100ms ease;
`;
const Format = styled.button`
display: flex;
align-items: baseline;
gap: 8px;
padding: 4px 0;
border: 0;
background: none;
font: inherit;
text-align: left;
cursor: var(--pointer);
&:hover {
${Label} {
color: ${s("textSecondary")};
}
${Value} {
color: ${s("text")};
}
}
`;
+86 -16
View File
@@ -1,10 +1,27 @@
import copy from "copy-to-clipboard";
import type { MouseEvent } from "react";
import { useCallback } from "react";
import {
Suspense,
lazy,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import type { EditorNotice } from "../types";
// Loaded lazily so its browser-only dependency on Radix doesn't enter the
// editor schema's static import graph, which is also used on the server.
const ColorHoverCard = lazy(() => import("./ColorHoverCard"));
/** Time in ms the pointer must rest on the swatch before the card opens. */
const OPEN_DELAY = 400;
/** Time in ms the card stays open after the pointer leaves, to allow travel. */
const CLOSE_DELAY = 200;
interface Props {
/** The CSS color the swatch represents, in its original notation. */
color: string;
@@ -16,10 +33,31 @@ interface Props {
/**
* A small colored circle rendered after a CSS color inside inline code. Clicking
* it copies the color to the clipboard.
* it copies the color to the clipboard, hovering reveals a larger preview and
* the color translated into other notations.
*/
export function ColorSwatch({ color, luminance, onNotice }: Props) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [hovered, setHovered] = useState(false);
const timeout = useRef<ReturnType<typeof setTimeout>>();
const scheduleOpen = useCallback((value: boolean, delay: number) => {
clearTimeout(timeout.current);
timeout.current = setTimeout(() => setOpen(value), delay);
}, []);
useEffect(() => () => clearTimeout(timeout.current), []);
const handleMouseEnter = useCallback(() => {
setHovered(true);
scheduleOpen(true, OPEN_DELAY);
}, [scheduleOpen]);
const handleMouseLeave = useCallback(
() => scheduleOpen(false, CLOSE_DELAY),
[scheduleOpen]
);
const handleMouseDown = useCallback((event: MouseEvent) => {
// Prevent the editor from moving the cursor into the code mark on click.
@@ -36,27 +74,46 @@ export function ColorSwatch({ color, luminance, onNotice }: Props) {
[color, onNotice, t]
);
return (
<Swatch
const trigger = (
<SwatchTarget
aria-hidden="true"
title={t("Click to copy")}
$luminance={luminance}
style={{ backgroundColor: color }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseDown={handleMouseDown}
onClick={handleClick}
/>
>
<Swatch $luminance={luminance} style={{ backgroundColor: color }} />
</SwatchTarget>
);
// A document can contain many swatches, so the card is only loaded and
// mounted once the pointer has actually reached one.
if (!hovered) {
return trigger;
}
return (
<Suspense fallback={trigger}>
<ColorHoverCard
color={color}
open={open}
onOpenChange={setOpen}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onNotice={onNotice}
>
{trigger}
</ColorHoverCard>
</Suspense>
);
}
const Swatch = styled.span<{ $luminance: number }>`
display: inline-block;
width: 0.75em;
height: 0.75em;
margin-left: 0.3em;
vertical-align: -0.05em;
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
background-clip: padding-box;
cursor: var(--pointer);
transition: transform 100ms ease;
/* Outline colors that would otherwise blend into the current background. */
@@ -65,12 +122,25 @@ const Swatch = styled.span<{ $luminance: number }>`
css`
outline: 1px solid ${props.theme.codeBorder};
`}
`;
&:hover {
/**
* Wraps the swatch at a fixed size so that scaling on hover doesn't move the
* bounds the hover card is positioned against.
*/
const SwatchTarget = styled.span`
display: inline-block;
width: 0.75em;
height: 0.75em;
margin-left: 0.3em;
vertical-align: -0.05em;
cursor: var(--pointer);
&:hover ${Swatch} {
transform: scale(1.1);
}
&:active {
&:active ${Swatch} {
transform: scale(0.9);
}
`;
+47 -1
View File
@@ -1,4 +1,4 @@
import { validateColorHex } from "./color";
import { toColorFormats, validateColorHex } from "./color";
describe("validateColorHex", () => {
it("accepts 3-digit hex", () => {
@@ -54,3 +54,49 @@ describe("validateColorHex", () => {
expect(validateColorHex("#fff;")).toBe(false);
});
});
describe("toColorFormats", () => {
it("translates hex into rgb and hsl", () => {
expect(toColorFormats("#ff0000")).toEqual({
hex: "#FF0000",
rgb: "rgb(255, 0, 0)",
hsl: "hsl(0, 100%, 50%)",
});
});
it("returns shorthand hex without expanding it", () => {
expect(toColorFormats("#fff")).toEqual({
hex: "#FFF",
rgb: "rgb(255, 255, 255)",
hsl: "hsl(0, 0%, 100%)",
});
});
it("returns the input notation unchanged", () => {
expect(toColorFormats("hsl(68, 69%, 45%)").hsl).toBe("hsl(68, 69%, 45%)");
expect(toColorFormats("rgba(0,0,0,.25)").rgb).toBe("rgba(0,0,0,.25)");
expect(toColorFormats("hsla(0, 0%, 0%, 0.253)").hsl).toBe(
"hsla(0, 0%, 0%, 0.253)"
);
});
it("preserves alpha across notations", () => {
expect(toColorFormats("#ff000080")).toEqual({
hex: "#FF000080",
rgb: "rgba(255, 0, 0, 0.5)",
hsl: "hsla(0, 100%, 50%, 0.5)",
});
});
it("accepts rgb and hsl input", () => {
expect(toColorFormats("rgb(0, 0, 255)").hex).toBe("#0000FF");
expect(toColorFormats("hsl(120, 100%, 50%)").hex).toBe("#00FF00");
expect(toColorFormats("rgba(0, 0, 0, 0.25)").hsl).toBe(
"hsla(0, 0%, 0%, 0.25)"
);
});
it("throws for values that are not colors", () => {
expect(() => toColorFormats("rgb(foo)")).toThrow();
});
});
+52 -1
View File
@@ -1,5 +1,5 @@
import md5 from "crypto-js/md5";
import { darken, parseToRgb } from "polished";
import { darken, parseToHsl, parseToRgb } from "polished";
import theme from "../styles/theme";
import type { RgbaColor } from "polished/lib/types/color";
@@ -67,6 +67,57 @@ export const rgbaToHex = ({ red, green, blue, alpha }: RgbaColor): string => {
return "#" + toHex(red) + toHex(green) + toHex(blue) + alphaHex;
};
export interface ColorFormats {
/** The color in uppercase hex notation, with an alpha pair when translucent. */
hex: string;
/** The color in `rgb()` notation, or `rgba()` when translucent. */
rgb: string;
/** The color in `hsl()` notation, or `hsla()` when translucent. */
hsl: string;
}
/**
* Translates a CSS color into the equivalent hex, rgb, and hsl notations. The
* notation the color was given in is returned unchanged, so that no precision
* is lost to a round trip through another color space.
*
* @param color - a color string in any notation understood by polished.
* @returns the same color expressed in each notation.
* @throws if the string is not a valid CSS color.
*/
export const toColorFormats = (color: string): ColorFormats => {
const rgb = parseToRgb(color);
const hsl = parseToHsl(color);
const alpha = "alpha" in rgb && rgb.alpha !== undefined ? rgb.alpha : 1;
const hue = round(hsl.hue);
const saturation = round(hsl.saturation * 100);
const lightness = round(hsl.lightness * 100);
const opacity = round(alpha, 2);
const formats: ColorFormats = {
hex: rgbaToHex({ ...rgb, alpha }).toUpperCase(),
rgb:
alpha < 1
? `rgba(${rgb.red}, ${rgb.green}, ${rgb.blue}, ${opacity})`
: `rgb(${rgb.red}, ${rgb.green}, ${rgb.blue})`,
hsl:
alpha < 1
? `hsla(${hue}, ${saturation}%, ${lightness}%, ${opacity})`
: `hsl(${hue}, ${saturation}%, ${lightness}%)`,
};
if (validateColorHex(color)) {
formats.hex = color.toUpperCase();
} else if (/^rgba?\(/i.test(color)) {
formats.rgb = color;
} else if (/^hsla?\(/i.test(color)) {
formats.hsl = color;
}
return formats;
};
interface PresetColor {
hex: string;
name: string;