Add Swatch component to @penpot/ui

This commit is contained in:
Andrey Antukh
2026-04-07 20:51:34 +00:00
parent 828dcb3a96
commit fcc29f2152
6 changed files with 610 additions and 0 deletions

View File

@@ -43,6 +43,7 @@ frontend/packages/ui/
│ │ │ └── RawSvg.spec.tsx
│ │ └── typography/ # Text, Heading components + shared utilities
│ └── product/ # Product-level components (e.g. Cta)
│ └── utilities/ # Utility components (e.g. Swatch)
├── eslint.config.mjs # ESLint 9 flat config (TypeScript + React)
├── stylelint.config.mjs # Stylelint config (mirrors frontend/)
├── vite.config.mts # Vite lib build + Vitest config
@@ -64,6 +65,7 @@ Components are organised to mirror the CLJS source tree
| `ds/product/cta.cljs` | `src/lib/product/Cta.tsx` |
| `ds/buttons/button.cljs` | `src/lib/buttons/Button.tsx` |
| `ds/buttons/icon_button.cljs` | `src/lib/buttons/IconButton.tsx` |
| `ds/utilities/swatch.cljs` | `src/lib/utilities/Swatch.tsx` |
### Known Tooling Notes

View File

@@ -22,3 +22,11 @@ export type {
IconButtonProps,
IconButtonVariant,
} from "./lib/buttons/IconButton";
export { Swatch } from "./lib/utilities/Swatch";
export type {
SwatchProps,
SwatchBackground,
SwatchGradient,
SwatchGradientStop,
SwatchSize,
} from "./lib/utilities/Swatch";

View File

@@ -0,0 +1,131 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "../_ds/_borders.scss" as *;
@use "../_ds/_sizes.scss" as *;
@property --solid-color-overlay {
syntax: "<color>";
inherits: false;
initial-value: rgb(0 0 0 / 0%);
}
.swatch {
--border-color: var(--color-accent-primary-muted);
--border-radius: #{$br-4};
--border-color-active: var(--color-foreground-primary);
--border-color-active-inset: var(--color-background-primary);
--checkerboard-background: repeating-conic-gradient(lightgray 0% 25%, white 0% 50%);
--checkerboard-size: 0.5rem 0.5rem;
border: $b-1 solid var(--border-color);
border-radius: var(--border-radius);
overflow: hidden;
&:focus-visible {
--border-color: var(--color-accent-primary);
}
}
.small {
inline-size: $sz-16;
block-size: $sz-16;
}
.medium {
--checkerboard-size: 1rem 1rem;
inline-size: $sz-24;
block-size: $sz-24;
}
.large {
--checkerboard-size: 2rem 2rem;
inline-size: $sz-48;
block-size: $sz-48;
}
.rounded {
--border-radius: #{$br-circle};
}
.active {
--border-color: var(--border-color-active);
position: relative;
&::before {
content: "";
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
inline-size: 100%;
block-size: 100%;
border-radius: 3px;
box-shadow: 0 0 0 1px var(--border-color-active-inset) inset;
}
}
.interactive {
cursor: pointer;
appearance: none;
margin: 0;
padding: 0;
background: none;
&:hover {
--border-color: var(--color-accent-primary-muted);
border-width: $b-2;
}
}
.swatch-gradient {
block-size: 100%;
display: block;
background-size: cover, var(--checkerboard-size);
background-position: center, center;
background-repeat: no-repeat, repeat;
}
.swatch-image {
block-size: 100%;
display: block;
background-size: cover, var(--checkerboard-size);
background-position: center, center;
background-repeat: no-repeat, repeat;
}
.swatch-opacity {
block-size: 100%;
display: grid;
grid-template-columns: auto auto;
}
.swatch-opacity-side-transparency {
background-image:
linear-gradient(var(--solid-color-overlay), var(--solid-color-overlay)), var(--checkerboard-background);
background-size: cover, var(--checkerboard-size);
background-position: center, center;
background-repeat: no-repeat, repeat;
clip-path: inset(0 0 0 0 round 0 #{$br-4} #{$br-4} 0);
}
.swatch-opacity-side-solid-color {
background: var(--solid-color-overlay);
background-size: cover;
}
.swatch-solid-side,
.swatch-opacity-side {
flex: 1;
display: block;
}
.swatch-error {
background: var(--color-background-primary);
}

View File

@@ -0,0 +1,136 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Swatch } from "./Swatch";
describe("Swatch", () => {
it("should render as a div when no onClick is provided", () => {
const { container } = render(<Swatch background={{ color: "#ff0000" }} />);
const el = container.firstElementChild;
expect(el?.tagName.toLowerCase()).toBe("div");
});
it("should render as a button when onClick is provided", () => {
const { container } = render(
<Swatch background={{ color: "#ff0000" }} onClick={() => {}} />,
);
const el = container.firstElementChild;
expect(el?.tagName.toLowerCase()).toBe("button");
});
it("should apply small size class by default", () => {
const { container } = render(<Swatch background={{ color: "#ff0000" }} />);
const el = container.firstElementChild;
expect(el?.getAttribute("class")).toContain("small");
});
it("should apply medium size class when size='medium'", () => {
const { container } = render(
<Swatch background={{ color: "#ff0000" }} size="medium" />,
);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"medium",
);
});
it("should apply large size class when size='large'", () => {
const { container } = render(
<Swatch background={{ color: "#ff0000" }} size="large" />,
);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"large",
);
});
it("should apply active class when active=true", () => {
const { container } = render(
<Swatch background={{ color: "#ff0000" }} active />,
);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"active",
);
});
it("should apply rounded class when background has refId", () => {
const { container } = render(
<Swatch background={{ color: "#ff0000", refId: "some-id" }} />,
);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"rounded",
);
});
it("should apply square class when background has no refId", () => {
const { container } = render(<Swatch background={{ color: "#ff0000" }} />);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"square",
);
});
it("should apply interactive class when onClick is provided", () => {
const { container } = render(
<Swatch background={{ color: "#ff0000" }} onClick={() => {}} />,
);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"interactive",
);
});
it("should call onClick with background and event when clicked", () => {
const bg = { color: "#ff0000" };
const onClickMock = vi.fn();
const { container } = render(
<Swatch background={bg} onClick={onClickMock} />,
);
const button = container.firstElementChild as HTMLButtonElement;
button.click();
expect(onClickMock).toHaveBeenCalledTimes(1);
expect(onClickMock.mock.calls[0][0]).toBe(bg);
});
it("should render gradient inner div when gradient is provided", () => {
const { container } = render(
<Swatch
background={{
gradient: {
type: "linear",
stops: [
{ color: "#000000", opacity: 1, offset: 0 },
{ color: "#ffffff", opacity: 1, offset: 1 },
],
},
}}
/>,
);
const inner = container.querySelector("[class*='swatch-gradient']");
expect(inner).toBeTruthy();
});
it("should render image inner div when imageUri is provided", () => {
const { container } = render(
<Swatch background={{ imageUri: "https://example.com/img.png" }} />,
);
const inner = container.querySelector("[class*='swatch-image']");
expect(inner).toBeTruthy();
});
it("should render error inner div when hasErrors=true", () => {
const { container } = render(<Swatch hasErrors />);
const inner = container.querySelector("[class*='swatch-error']");
expect(inner).toBeTruthy();
});
it("should forward className to the root element", () => {
const { container } = render(
<Swatch background={{ color: "#ff0000" }} className="custom-cls" />,
);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"custom-cls",
);
});
});

View File

@@ -0,0 +1,104 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Swatch } from "./Swatch";
const meta = {
title: "Foundations/Utilities/Swatch",
component: Swatch,
argTypes: {
size: {
control: "select",
options: ["small", "medium", "large"],
},
active: { control: "boolean" },
hasErrors: { control: "boolean" },
},
args: {
background: { color: "#7efff5" },
size: "medium",
active: false,
hasErrors: false,
},
} satisfies Meta<typeof Swatch>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithOpacity: Story = {
args: {
background: { color: "#2f226c", opacity: 0.5 },
},
};
const stops = [
{ color: "#151035", opacity: 1, offset: 0 },
{ color: "#2f226c", opacity: 0.5, offset: 1 },
];
export const LinearGradient: Story = {
args: {
background: {
gradient: {
type: "linear",
stops,
},
},
},
};
export const RadialGradient: Story = {
args: {
background: {
gradient: {
type: "radial",
stops,
},
},
},
};
export const Rounded: Story = {
args: {
background: {
refId: "some-uuid",
color: "#2f226c",
opacity: 0.5,
},
},
};
export const Clickable: Story = {
args: {
onClick: (bg, _e) => {
console.warn("clicked", bg);
},
"aria-label": "Click swatch",
},
};
export const Large: Story = {
args: {
size: "large",
background: { color: "#ff5733" },
},
};
export const Error: Story = {
args: {
hasErrors: true,
},
};
export const Active: Story = {
args: {
active: true,
background: { color: "#4caf50" },
},
};

View File

@@ -0,0 +1,229 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
import { memo, useCallback, useId, useRef } from "react";
import clsx from "clsx";
import styles from "./Swatch.module.scss";
// ---------------------------------------------------------------------------
// Color types
// ---------------------------------------------------------------------------
export interface SwatchGradientStop {
color: string;
opacity: number;
offset: number;
}
export interface SwatchGradient {
type: "linear" | "radial";
stops: SwatchGradientStop[];
}
export interface SwatchBackground {
/** Hex colour string, e.g. "#7efff5" */
color?: string;
/** Opacity in [0, 1]. Defaults to 1. */
opacity?: number;
gradient?: SwatchGradient;
/** Pre-resolved image URI for image-fill swatches */
imageUri?: string;
/** When set the swatch renders with a rounded (circle) border-radius */
refId?: string;
}
// ---------------------------------------------------------------------------
// CSS helpers (mirrors app.util.color)
// ---------------------------------------------------------------------------
function gradientToCss(gradient: SwatchGradient): string {
const stopsCss = gradient.stops
.map(({ color, opacity, offset }) => {
const hex = color.replace("#", "");
const full = hex.length === 3 ? hex.replace(/./g, (c) => c + c) : hex;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity}) ${offset * 100}%`;
})
.join(", ");
return gradient.type === "linear"
? `linear-gradient(to bottom, ${stopsCss})`
: `radial-gradient(circle, ${stopsCss})`;
}
function colorToBackground(background: SwatchBackground): string {
const { color, opacity = 1, gradient } = background;
if (gradient) {
return gradientToCss(gradient);
}
if (color) {
const hex = color.replace("#", "");
const full = hex.length === 3 ? hex.replace(/./g, (c) => c + c) : hex;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
return "transparent";
}
function colorToSolidBackground(background: SwatchBackground): string {
return colorToBackground({ ...background, opacity: 1 });
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export type SwatchSize = "small" | "medium" | "large";
export interface SwatchProps {
background?: SwatchBackground;
/** Visual size of the swatch. Defaults to "small". */
size?: SwatchSize;
/** Whether the swatch is in active/selected state */
active?: boolean;
/** Show an error indicator instead of the colour */
hasErrors?: boolean;
/** Additional CSS class names */
className?: string;
/** Click handler — when provided the swatch renders as a <button> */
onClick?: (
background: SwatchBackground | undefined,
event: React.MouseEvent,
) => void;
/** Tooltip content — currently unused until Tooltip is migrated */
tooltipContent?: React.ReactNode;
/** Whether to show a tooltip on hover. Defaults to true. */
showTooltip?: boolean;
/** Accessible label */
"aria-label"?: string;
}
function SwatchInner({
background,
size = "small",
active = false,
hasErrors = false,
className,
onClick,
tooltipContent: _tooltipContent,
showTooltip: _showTooltip,
"aria-label": ariaLabel,
}: SwatchProps) {
const isReadOnly = onClick == null;
const isRounded = background?.refId != null;
const elementId = useId();
// Separate refs typed per element — will be used for Tooltip once migrated
const divRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const handleClick = useCallback(
(event: React.MouseEvent) => {
onClick?.(background, event);
},
[background, onClick],
);
const rootClass = clsx(
styles.swatch,
{
[styles.small]: size === "small",
[styles.medium]: size === "medium",
[styles.large]: size === "large",
[styles.square]: !isRounded,
[styles.rounded]: isRounded,
[styles.active]: active,
[styles.interactive]: !isReadOnly,
},
className,
);
const hasOpacity =
background?.color != null &&
background.opacity != null &&
background.opacity < 1;
const gradientType = background?.gradient?.type;
const imageUri = background?.imageUri;
let innerContent: React.ReactNode;
if (gradientType != null && background?.gradient != null) {
const gradientCss = gradientToCss(background.gradient);
const checkerboard =
"repeating-conic-gradient(lightgray 0% 25%, white 0% 50%)";
innerContent = (
<div
className={styles["swatch-gradient"]}
style={{
backgroundImage: `${gradientCss}, ${checkerboard}`,
}}
/>
);
} else if (imageUri != null) {
innerContent = (
<div
className={styles["swatch-image"]}
style={{ backgroundImage: `url(${imageUri})` }}
/>
);
} else if (hasErrors) {
innerContent = <div className={styles["swatch-error"]} />;
} else {
const solidColor = background
? colorToSolidBackground(background)
: "transparent";
const overlayColor = background
? colorToBackground(background)
: "transparent";
innerContent = (
<div className={styles["swatch-opacity"]}>
<div
className={styles["swatch-solid-side"]}
style={{ background: solidColor }}
/>
<div
className={clsx(styles["swatch-opacity-side"], {
[styles["swatch-opacity-side-transparency"]]: hasOpacity,
[styles["swatch-opacity-side-solid-color"]]: !hasOpacity,
})}
style={
{ "--solid-color-overlay": overlayColor } as React.CSSProperties
}
/>
</div>
);
}
const sharedProps = {
className: rootClass,
"aria-labelledby": elementId,
};
// TODO: wrap in <Tooltip> once ds/tooltip/tooltip.cljs is migrated
return isReadOnly ? (
<div {...sharedProps} ref={divRef} aria-label={ariaLabel} id={elementId}>
{innerContent}
</div>
) : (
<button
{...sharedProps}
ref={buttonRef}
type="button"
aria-label={ariaLabel}
id={elementId}
onClick={handleClick}
>
{innerContent}
</button>
);
}
export const Swatch = memo(SwatchInner);