mirror of
https://github.com/penpot/penpot.git
synced 2026-04-11 21:58:38 +02:00
✨ Add Swatch component to @penpot/ui
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user