Add RawSvg component to @penpot/ui

This commit is contained in:
Andrey Antukh 2026-04-07 20:46:07 +00:00
parent 425a140a44
commit 828dcb3a96
6 changed files with 173 additions and 2 deletions

View File

@ -32,11 +32,15 @@ frontend/packages/ui/
│ │ └── IconButton.spec.tsx
│ ├── example/ # Example component (reference)
│ ├── foundations/
│ │ ├── assets/ # Icon component
│ │ ├── assets/ # Icon, RawSvg components
│ │ │ ├── Icon.tsx
│ │ │ ├── Icon.module.scss
│ │ │ ├── Icon.stories.tsx
│ │ │ └── Icon.spec.tsx
│ │ │ ├── Icon.spec.tsx
│ │ │ ├── RawSvg.tsx
│ │ │ ├── RawSvg.module.scss
│ │ │ ├── RawSvg.stories.tsx
│ │ │ └── RawSvg.spec.tsx
│ │ └── typography/ # Text, Heading components + shared utilities
│ └── product/ # Product-level components (e.g. Cta)
├── eslint.config.mjs # ESLint 9 flat config (TypeScript + React)
@ -56,6 +60,7 @@ Components are organised to mirror the CLJS source tree
| `ds/foundations/typography/text.cljs` | `src/lib/foundations/typography/Text.tsx` |
| `ds/foundations/typography/heading.cljs` | `src/lib/foundations/typography/Heading.tsx` |
| `ds/foundations/assets/icon.cljs` | `src/lib/foundations/assets/Icon.tsx` |
| `ds/foundations/assets/raw_svg.cljs` | `src/lib/foundations/assets/RawSvg.tsx` |
| `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` |

View File

@ -13,6 +13,8 @@ export { Cta } from "./lib/product/Cta";
export type { CtaProps } from "./lib/product/Cta";
export { Icon, iconIds } from "./lib/foundations/assets/Icon";
export type { IconId, IconProps } from "./lib/foundations/assets/Icon";
export { RawSvg, rawSvgIds } from "./lib/foundations/assets/RawSvg";
export type { RawSvgId, RawSvgProps } from "./lib/foundations/assets/RawSvg";
export { Button } from "./lib/buttons/Button";
export type { ButtonProps, ButtonVariant } from "./lib/buttons/Button";
export { IconButton } from "./lib/buttons/IconButton";

View File

@ -0,0 +1,8 @@
// 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
// RawSvg has no component-specific styles; all sizing and colour is
// controlled by the consumer via props (width, height, style, etc.).

View File

@ -0,0 +1,63 @@
// 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 { RawSvg, rawSvgIds } from "./RawSvg";
describe("RawSvg", () => {
it("should render successfully", () => {
const { baseElement } = render(<RawSvg id="penpot-logo" />);
expect(baseElement).toBeTruthy();
});
it("renders an <svg> element", () => {
const { container } = render(<RawSvg id="penpot-logo" />);
expect(container.querySelector("svg")).not.toBeNull();
});
it("renders a <use> element referencing the correct asset href", () => {
const { container } = render(<RawSvg id="brand-github" />);
const use = container.querySelector("use");
expect(use?.getAttribute("href")).toBe("#asset-brand-github");
});
it("forwards width and height props to the svg element", () => {
const { container } = render(
<RawSvg id="penpot-logo" width={200} height={48} />,
);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("width")).toBe("200");
expect(svg?.getAttribute("height")).toBe("48");
});
it("forwards className to the svg element", () => {
const { container } = render(
<RawSvg id="penpot-logo" className="my-svg" />,
);
expect(container.querySelector("svg")?.getAttribute("class")).toContain(
"my-svg",
);
});
it("forwards arbitrary svg props", () => {
const { container } = render(
<RawSvg id="penpot-logo" data-testid="raw-svg" aria-label="logo" />,
);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("data-testid")).toBe("raw-svg");
expect(svg?.getAttribute("aria-label")).toBe("logo");
});
it("exports a non-empty rawSvgIds array", () => {
expect(rawSvgIds.length).toBeGreaterThan(0);
});
it("includes expected asset IDs in rawSvgIds", () => {
expect(rawSvgIds).toContain("penpot-logo");
expect(rawSvgIds).toContain("brand-github");
expect(rawSvgIds).toContain("loader");
});
});

View File

@ -0,0 +1,36 @@
// 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 { rawSvgIds, RawSvg } from "./RawSvg";
const meta = {
title: "Foundations/Assets/RawSvg",
component: RawSvg,
args: {
id: "brand-gitlab",
width: 200,
},
argTypes: {
id: {
options: rawSvgIds,
control: { type: "select" },
},
},
} satisfies Meta<typeof RawSvg>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const PenpotLogo: Story = {
args: { id: "penpot-logo", width: 200, height: 48 },
};
export const PenpotLogoIcon: Story = {
args: { id: "penpot-logo-icon", width: 48, height: 48 },
};

View File

@ -0,0 +1,57 @@
// 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 ComponentPropsWithRef, memo } from "react";
// ---------------------------------------------------------------------------
// Raw SVG asset ID catalogue
// ---------------------------------------------------------------------------
export const rawSvgIds = [
"brand-openid",
"brand-github",
"brand-gitlab",
"brand-google",
"loader",
"logo-error-screen",
"logo-subscription",
"logo-subscription-light",
"nitrate-welcome",
"marketing-arrows",
"marketing-exchange",
"marketing-file",
"marketing-layers",
"penpot-logo",
"penpot-logo-icon",
"empty-placeholder-1-left",
"empty-placeholder-1-right",
"empty-placeholder-2-left",
"empty-placeholder-2-right",
] as const;
export type RawSvgId = (typeof rawSvgIds)[number];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export interface RawSvgProps extends Omit<
ComponentPropsWithRef<"svg">,
"children"
> {
/** Raw SVG asset identifier — must be one of the registered asset IDs. */
id: RawSvgId;
}
function RawSvgInner({ id, ...rest }: RawSvgProps) {
return (
<svg {...rest}>
<use href={`#asset-${id}`} />
</svg>
);
}
export const RawSvg = memo(RawSvgInner);