From fcc29f2152f4409ccbe6a8284bd54b525e4005fa Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 7 Apr 2026 20:51:34 +0000 Subject: [PATCH] :sparkles: Add Swatch component to @penpot/ui --- frontend/packages/ui/AGENTS.md | 2 + frontend/packages/ui/src/index.ts | 8 + .../ui/src/lib/utilities/Swatch.module.scss | 131 ++++++++++ .../ui/src/lib/utilities/Swatch.spec.tsx | 136 +++++++++++ .../ui/src/lib/utilities/Swatch.stories.tsx | 104 ++++++++ .../packages/ui/src/lib/utilities/Swatch.tsx | 229 ++++++++++++++++++ 6 files changed, 610 insertions(+) create mode 100644 frontend/packages/ui/src/lib/utilities/Swatch.module.scss create mode 100644 frontend/packages/ui/src/lib/utilities/Swatch.spec.tsx create mode 100644 frontend/packages/ui/src/lib/utilities/Swatch.stories.tsx create mode 100644 frontend/packages/ui/src/lib/utilities/Swatch.tsx diff --git a/frontend/packages/ui/AGENTS.md b/frontend/packages/ui/AGENTS.md index 82d74b22f8..599b2be8e1 100644 --- a/frontend/packages/ui/AGENTS.md +++ b/frontend/packages/ui/AGENTS.md @@ -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 diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts index 57f74fa36c..1c86dc2828 100644 --- a/frontend/packages/ui/src/index.ts +++ b/frontend/packages/ui/src/index.ts @@ -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"; diff --git a/frontend/packages/ui/src/lib/utilities/Swatch.module.scss b/frontend/packages/ui/src/lib/utilities/Swatch.module.scss new file mode 100644 index 0000000000..a2b6faeecd --- /dev/null +++ b/frontend/packages/ui/src/lib/utilities/Swatch.module.scss @@ -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: ""; + 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); +} diff --git a/frontend/packages/ui/src/lib/utilities/Swatch.spec.tsx b/frontend/packages/ui/src/lib/utilities/Swatch.spec.tsx new file mode 100644 index 0000000000..da84ce2be2 --- /dev/null +++ b/frontend/packages/ui/src/lib/utilities/Swatch.spec.tsx @@ -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(); + const el = container.firstElementChild; + expect(el?.tagName.toLowerCase()).toBe("div"); + }); + + it("should render as a button when onClick is provided", () => { + const { container } = render( + {}} />, + ); + const el = container.firstElementChild; + expect(el?.tagName.toLowerCase()).toBe("button"); + }); + + it("should apply small size class by default", () => { + const { container } = render(); + const el = container.firstElementChild; + expect(el?.getAttribute("class")).toContain("small"); + }); + + it("should apply medium size class when size='medium'", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "medium", + ); + }); + + it("should apply large size class when size='large'", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "large", + ); + }); + + it("should apply active class when active=true", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "active", + ); + }); + + it("should apply rounded class when background has refId", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "rounded", + ); + }); + + it("should apply square class when background has no refId", () => { + const { container } = render(); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "square", + ); + }); + + it("should apply interactive class when onClick is provided", () => { + const { container } = render( + {}} />, + ); + 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( + , + ); + 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( + , + ); + const inner = container.querySelector("[class*='swatch-gradient']"); + expect(inner).toBeTruthy(); + }); + + it("should render image inner div when imageUri is provided", () => { + const { container } = render( + , + ); + const inner = container.querySelector("[class*='swatch-image']"); + expect(inner).toBeTruthy(); + }); + + it("should render error inner div when hasErrors=true", () => { + const { container } = render(); + const inner = container.querySelector("[class*='swatch-error']"); + expect(inner).toBeTruthy(); + }); + + it("should forward className to the root element", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "custom-cls", + ); + }); +}); diff --git a/frontend/packages/ui/src/lib/utilities/Swatch.stories.tsx b/frontend/packages/ui/src/lib/utilities/Swatch.stories.tsx new file mode 100644 index 0000000000..076fc4ce36 --- /dev/null +++ b/frontend/packages/ui/src/lib/utilities/Swatch.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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" }, + }, +}; diff --git a/frontend/packages/ui/src/lib/utilities/Swatch.tsx b/frontend/packages/ui/src/lib/utilities/Swatch.tsx new file mode 100644 index 0000000000..a28c38d076 --- /dev/null +++ b/frontend/packages/ui/src/lib/utilities/Swatch.tsx @@ -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 + ); +} + +export const Swatch = memo(SwatchInner);