mirror of
https://github.com/penpot/penpot.git
synced 2026-08-06 12:58:55 +00:00
✨ Add Swatch component to @penpot/ui
This commit is contained in:
parent
828dcb3a96
commit
fcc29f2152
@ -43,6 +43,7 @@ frontend/packages/ui/
|
|||||||
│ │ │ └── RawSvg.spec.tsx
|
│ │ │ └── RawSvg.spec.tsx
|
||||||
│ │ └── typography/ # Text, Heading components + shared utilities
|
│ │ └── typography/ # Text, Heading components + shared utilities
|
||||||
│ └── product/ # Product-level components (e.g. Cta)
|
│ └── product/ # Product-level components (e.g. Cta)
|
||||||
|
│ └── utilities/ # Utility components (e.g. Swatch)
|
||||||
├── eslint.config.mjs # ESLint 9 flat config (TypeScript + React)
|
├── eslint.config.mjs # ESLint 9 flat config (TypeScript + React)
|
||||||
├── stylelint.config.mjs # Stylelint config (mirrors frontend/)
|
├── stylelint.config.mjs # Stylelint config (mirrors frontend/)
|
||||||
├── vite.config.mts # Vite lib build + Vitest config
|
├── 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/product/cta.cljs` | `src/lib/product/Cta.tsx` |
|
||||||
| `ds/buttons/button.cljs` | `src/lib/buttons/Button.tsx` |
|
| `ds/buttons/button.cljs` | `src/lib/buttons/Button.tsx` |
|
||||||
| `ds/buttons/icon_button.cljs` | `src/lib/buttons/IconButton.tsx` |
|
| `ds/buttons/icon_button.cljs` | `src/lib/buttons/IconButton.tsx` |
|
||||||
|
| `ds/utilities/swatch.cljs` | `src/lib/utilities/Swatch.tsx` |
|
||||||
|
|
||||||
### Known Tooling Notes
|
### Known Tooling Notes
|
||||||
|
|
||||||
|
|||||||
@ -22,3 +22,11 @@ export type {
|
|||||||
IconButtonProps,
|
IconButtonProps,
|
||||||
IconButtonVariant,
|
IconButtonVariant,
|
||||||
} from "./lib/buttons/IconButton";
|
} from "./lib/buttons/IconButton";
|
||||||
|
export { Swatch } from "./lib/utilities/Swatch";
|
||||||
|
export type {
|
||||||
|
SwatchProps,
|
||||||
|
SwatchBackground,
|
||||||
|
SwatchGradient,
|
||||||
|
SwatchGradientStop,
|
||||||
|
SwatchSize,
|
||||||
|
} from "./lib/utilities/Swatch";
|
||||||
|
|||||||
131
frontend/packages/ui/src/lib/utilities/Swatch.module.scss
Normal file
131
frontend/packages/ui/src/lib/utilities/Swatch.module.scss
Normal 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);
|
||||||
|
}
|
||||||
136
frontend/packages/ui/src/lib/utilities/Swatch.spec.tsx
Normal file
136
frontend/packages/ui/src/lib/utilities/Swatch.spec.tsx
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
104
frontend/packages/ui/src/lib/utilities/Swatch.stories.tsx
Normal file
104
frontend/packages/ui/src/lib/utilities/Swatch.stories.tsx
Normal 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" },
|
||||||
|
},
|
||||||
|
};
|
||||||
229
frontend/packages/ui/src/lib/utilities/Swatch.tsx
Normal file
229
frontend/packages/ui/src/lib/utilities/Swatch.tsx
Normal 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);
|
||||||
Loading…
x
Reference in New Issue
Block a user