mirror of
https://github.com/penpot/penpot.git
synced 2026-05-12 19:43:48 +00:00
✨ Add IconButton component to @penpot/ui
This commit is contained in:
parent
42ea5def4f
commit
425a140a44
@ -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
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
50
frontend/packages/ui/src/lib/buttons/IconButton.module.scss
Normal file
50
frontend/packages/ui/src/lib/buttons/IconButton.module.scss
Normal 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};
|
||||
}
|
||||
112
frontend/packages/ui/src/lib/buttons/IconButton.spec.tsx
Normal file
112
frontend/packages/ui/src/lib/buttons/IconButton.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
59
frontend/packages/ui/src/lib/buttons/IconButton.stories.tsx
Normal file
59
frontend/packages/ui/src/lib/buttons/IconButton.stories.tsx
Normal 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 },
|
||||
};
|
||||
87
frontend/packages/ui/src/lib/buttons/IconButton.tsx
Normal file
87
frontend/packages/ui/src/lib/buttons/IconButton.tsx
Normal 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);
|
||||
Loading…
x
Reference in New Issue
Block a user