Add IconButton component to @penpot/ui

This commit is contained in:
Andrey Antukh 2026-04-07 20:40:42 +00:00
parent 42ea5def4f
commit 425a140a44
6 changed files with 319 additions and 1 deletions

View File

@ -25,7 +25,11 @@ frontend/packages/ui/
│ │ ├── Button.tsx
│ │ ├── Button.module.scss
│ │ ├── Button.stories.tsx
│ │ └── Button.spec.tsx
│ │ ├── Button.spec.tsx
│ │ ├── IconButton.tsx
│ │ ├── IconButton.module.scss
│ │ ├── IconButton.stories.tsx
│ │ └── IconButton.spec.tsx
│ ├── example/ # Example component (reference)
│ ├── foundations/
│ │ ├── assets/ # Icon component
@ -54,6 +58,7 @@ Components are organised to mirror the CLJS source tree
| `ds/foundations/assets/icon.cljs` | `src/lib/foundations/assets/Icon.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` |
### Known Tooling Notes

View File

@ -15,3 +15,8 @@ export { Icon, iconIds } from "./lib/foundations/assets/Icon";
export type { IconId, IconProps } from "./lib/foundations/assets/Icon";
export { Button } from "./lib/buttons/Button";
export type { ButtonProps, ButtonVariant } from "./lib/buttons/Button";
export { IconButton } from "./lib/buttons/IconButton";
export type {
IconButtonProps,
IconButtonVariant,
} from "./lib/buttons/IconButton";

View File

@ -0,0 +1,50 @@
// 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/_sizes.scss" as *;
@use "buttons" as *;
.icon-button {
@extend %base-button;
--button-width: #{$sz-32};
--button-height: #{$sz-32};
display: grid;
place-content: center;
}
.icon-button-primary {
@extend %base-button-primary;
}
.icon-button-secondary {
@extend %base-button-secondary;
}
.icon-button-ghost {
@extend %base-button-ghost;
}
.icon-button-destructive {
@extend %base-button-destructive;
}
.icon-button-action {
--button-bg-color: transparent;
--button-fg-color: var(--color-foreground-secondary);
--button-hover-bg-color: transparent;
--button-hover-fg-color: var(--color-accent-primary);
--button-active-bg-color: var(--color-background-quaternary);
--button-disabled-bg-color: transparent;
--button-disabled-fg-color: var(--color-accent-primary-muted);
--button-focus-bg-color: transparent;
--button-focus-fg-color: var(--color-accent-primary);
--button-focus-inner-ring-color: transparent;
--button-focus-outer-ring-color: var(--color-accent-primary);
--button-width: #{$sz-24};
--button-height: #{$sz-24};
}

View File

@ -0,0 +1,112 @@
// 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, screen } from "@testing-library/react";
import { IconButton } from "./IconButton";
describe("IconButton", () => {
it("should render successfully", () => {
const { baseElement } = render(<IconButton icon="pin" aria-label="Pin" />);
expect(baseElement).toBeTruthy();
});
it("renders a <button> element", () => {
render(<IconButton icon="pin" aria-label="Pin" />);
expect(screen.getByRole("button", { name: "Pin" })).toBeTruthy();
});
it("has type=button by default", () => {
render(<IconButton icon="pin" aria-label="Pin" />);
expect(screen.getByRole("button").getAttribute("type")).toBe("button");
});
it("renders an Icon inside", () => {
const { container } = render(<IconButton icon="add" aria-label="Add" />);
const use = container.querySelector("use");
expect(use?.getAttribute("href")).toBe("#icon-add");
});
it("Icon is aria-hidden", () => {
const { container } = render(<IconButton icon="pin" aria-label="Pin" />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
it("applies primary variant class by default", () => {
const { container } = render(<IconButton icon="pin" aria-label="Pin" />);
expect(container.querySelector("button")?.className).toContain(
"icon-button-primary",
);
});
it("applies secondary variant class", () => {
const { container } = render(
<IconButton icon="pin" aria-label="Pin" variant="secondary" />,
);
expect(container.querySelector("button")?.className).toContain(
"icon-button-secondary",
);
});
it("applies ghost variant class", () => {
const { container } = render(
<IconButton icon="pin" aria-label="Pin" variant="ghost" />,
);
expect(container.querySelector("button")?.className).toContain(
"icon-button-ghost",
);
});
it("applies destructive variant class", () => {
const { container } = render(
<IconButton icon="pin" aria-label="Pin" variant="destructive" />,
);
expect(container.querySelector("button")?.className).toContain(
"icon-button-destructive",
);
});
it("applies action variant class", () => {
const { container } = render(
<IconButton icon="pin" aria-label="Pin" variant="action" />,
);
expect(container.querySelector("button")?.className).toContain(
"icon-button-action",
);
});
it("forwards iconClass to the Icon element", () => {
const { container } = render(
<IconButton icon="pin" aria-label="Pin" iconClass="my-icon" />,
);
expect(container.querySelector("svg")?.getAttribute("class")).toContain(
"my-icon",
);
});
it("merges custom className onto the button", () => {
const { container } = render(
<IconButton icon="pin" aria-label="Pin" className="custom" />,
);
expect(container.querySelector("button")?.className).toContain("custom");
});
it("calls onRef with the button DOM node", () => {
const onRef = vi.fn();
render(<IconButton icon="pin" aria-label="Pin" onRef={onRef} />);
expect(onRef).toHaveBeenCalledWith(expect.any(HTMLButtonElement));
});
it("forwards arbitrary props to the button", () => {
render(<IconButton icon="pin" aria-label="Pin" data-testid="ib" />);
expect(screen.getByTestId("ib")).toBeTruthy();
});
it("supports disabled state", () => {
render(<IconButton icon="pin" aria-label="Pin" disabled />);
expect(screen.getByRole("button")).toHaveProperty("disabled", true);
});
});

View File

@ -0,0 +1,59 @@
// 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 { iconIds } from "../foundations/assets/Icon";
import { IconButton } from "./IconButton";
const meta = {
title: "Buttons/IconButton",
component: IconButton,
args: {
icon: "effects",
"aria-label": "Effects",
variant: "primary",
},
argTypes: {
icon: {
options: iconIds,
control: { type: "select" },
},
variant: {
options: ["primary", "secondary", "ghost", "destructive", "action"],
control: { type: "select" },
},
disabled: { control: "boolean" },
},
} satisfies Meta<typeof IconButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Primary: Story = {
args: { variant: "primary" },
};
export const Secondary: Story = {
args: { variant: "secondary" },
};
export const Ghost: Story = {
args: { variant: "ghost" },
};
export const Action: Story = {
args: { variant: "action" },
};
export const Destructive: Story = {
args: { variant: "destructive" },
};
export const Disabled: Story = {
args: { disabled: true },
};

View File

@ -0,0 +1,87 @@
// 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";
import clsx from "clsx";
import { Icon, type IconId } from "../foundations/assets/Icon";
import styles from "./IconButton.module.scss";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type IconButtonVariant =
| "primary"
| "secondary"
| "ghost"
| "destructive"
| "action";
export interface IconButtonProps extends Omit<
ComponentPropsWithRef<"button">,
"children"
> {
/** Icon to display. Required. */
icon: IconId;
/** Accessible label shown as tooltip text and used for aria-label. Required. */
"aria-label": string;
/** Visual variant. Defaults to "primary". */
variant?: IconButtonVariant;
/** Extra class forwarded to the Icon element. */
iconClass?: string;
/** Tooltip placement hint (used when Tooltip component is integrated). */
tooltipPlacement?:
| "top"
| "bottom"
| "left"
| "right"
| "top-right"
| "bottom-right"
| "bottom-left"
| "top-left";
/** Extra class forwarded to the tooltip wrapper (used when Tooltip is integrated). */
tooltipClass?: string;
/** Callback that receives the underlying button DOM node. */
onRef?: (node: HTMLButtonElement | null) => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
function IconButtonInner({
icon,
variant = "primary",
iconClass,
tooltipPlacement: _tooltipPlacement,
tooltipClass: _tooltipClass,
onRef,
className,
...rest
}: IconButtonProps) {
return (
<button
type="button"
className={clsx(
styles["icon-button"],
{
[styles["icon-button-primary"]]: variant === "primary",
[styles["icon-button-secondary"]]: variant === "secondary",
[styles["icon-button-ghost"]]: variant === "ghost",
[styles["icon-button-destructive"]]: variant === "destructive",
[styles["icon-button-action"]]: variant === "action",
},
className,
)}
ref={(node) => onRef?.(node)}
{...rest}
>
<Icon iconId={icon} aria-hidden className={iconClass} />
</button>
);
}
export const IconButton = memo(IconButtonInner);