Skip to content
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
},
"peerDependencies": {
"@pandacss/dev": ">=0.45",
"@solar-icons/react": ">=1.1",
"react": ">=18",
"react-dom": ">=18"
},
Expand Down
49 changes: 49 additions & 0 deletions src/components/base/badge/badge.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Badge } from "./badge";

const meta: Meta<typeof Badge> = {
title: "Base/Badge",
component: Badge,
parameters: { layout: "centered" },
};

export default meta;
type Story = StoryObj<typeof Badge>;

export const Variants: Story = {
render: () => (
<div style={{ display: "flex", gap: "8px" }}>
<Badge variant="solid">Solid</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="subtle">Subtle</Badge>
</div>
),
};

export const Sizes: Story = {
render: () => (
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<Badge size="sm">Small</Badge>
<Badge size="md">Medium</Badge>
</div>
),
};

export const WithDot: Story = {
render: () => (
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<Badge dot color="success">
Open Source
</Badge>
<Badge dot color="warning">
Beta
</Badge>
<Badge dot color="danger">
Deprecated
</Badge>
<Badge dot color="accent">
New
</Badge>
</div>
),
};
67 changes: 67 additions & 0 deletions src/components/base/badge/badge.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { sva } from "@/styled-system/css";

export const badgeStyles = sva({
slots: ["root", "dot"],
base: {
root: {
display: "inline-flex",
alignItems: "center",
gap: "1.5",
borderRadius: "full",
fontWeight: "medium",
lineHeight: "1.25",
fontFamily: "sans",
whiteSpace: "nowrap",
border: "1px solid transparent",
},
dot: {
borderRadius: "full",
flexShrink: "0",
},
},
variants: {
variant: {
solid: {
root: {
bg: "accent.DEFAULT",
color: "accent.fg",
},
},
outline: {
root: {
bg: "transparent",
color: "fg.DEFAULT",
borderColor: "border.DEFAULT",
},
},
subtle: {
root: {
bg: "bg.muted",
color: "fg.DEFAULT",
},
},
},
size: {
sm: {
root: { px: "2", py: "0.5", fontSize: "xs" },
dot: { w: "1.5", h: "1.5" },
},
md: {
root: { px: "2.5", py: "1", fontSize: "sm" },
dot: { w: "2", h: "2" },
},
},
color: {
default: { dot: { bg: "currentColor" } },
success: { dot: { bg: "#22c55e" } },
warning: { dot: { bg: "#f59e0b" } },
danger: { dot: { bg: "#ef4444" } },
accent: { dot: { bg: "accent.DEFAULT" } },
},
},
defaultVariants: {
variant: "subtle",
size: "md",
color: "default",
},
});
40 changes: 40 additions & 0 deletions src/components/base/badge/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { HTMLAttributes, ReactNode } from "react";
import { badgeStyles } from "./badge.styles";

export type BadgeVariant = "solid" | "outline" | "subtle";
export type BadgeSize = "sm" | "md";
export type BadgeColor =
| "default"
| "success"
| "warning"
| "danger"
| "accent";

export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
size?: BadgeSize;
color?: BadgeColor;
dot?: boolean;
children: ReactNode;
}

export function Badge({
variant = "subtle",
size = "md",
color = "default",
dot = false,
children,
className,
...rest
}: BadgeProps) {

Check warning on line 29 in src/components/base/badge/badge.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=version14_ui&issues=AZ5Jh_8OzymZYXV_FmQQ&open=AZ5Jh_8OzymZYXV_FmQQ&pullRequest=31
const styles = badgeStyles({ variant, size, color });
return (
<span
className={`${styles.root}${className ? ` ${className}` : ""}`}

Check warning on line 33 in src/components/base/badge/badge.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not use nested template literals.

See more on https://sonarcloud.io/project/issues?id=version14_ui&issues=AZ5Jh_8OzymZYXV_FmQR&open=AZ5Jh_8OzymZYXV_FmQR&pullRequest=31
{...rest}
>
{dot && <span aria-hidden="true" className={styles.dot} />}
{children}
</span>
);
}
2 changes: 2 additions & 0 deletions src/components/base/badge/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { BadgeColor, BadgeProps, BadgeSize, BadgeVariant } from "./badge";
export { Badge } from "./badge";
64 changes: 64 additions & 0 deletions src/components/base/button/button.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Icon } from "../../icon/icon";
import { Button } from "./button";

const meta: Meta<typeof Button> = {
title: "Base/Button",
component: Button,
parameters: { layout: "centered" },
argTypes: {
variant: { control: "select", options: ["primary", "ghost", "outline"] },
size: { control: "select", options: ["sm", "md", "lg"] },
},
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
args: { variant: "primary", children: "Get started" },
};

export const AllVariants: Story = {
render: () => (
<div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
<Button variant="primary">Primary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
</div>
),
};

export const Sizes: Story = {
render: () => (
<div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
),
};

export const Loading: Story = {
args: { isLoading: true, children: "Submitting" },
};

export const IconOnly: Story = {
render: () => (
<div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
<Button isIconOnly size="sm">
<Icon name="Plus" size="sm" />
</Button>
<Button isIconOnly size="md">
<Icon name="Plus" size="md" />
</Button>
<Button isIconOnly size="lg">
<Icon name="Plus" size="lg" />
</Button>
</div>
),
};

export const Disabled: Story = {
args: { disabled: true, children: "Disabled" },
};
94 changes: 94 additions & 0 deletions src/components/base/button/button.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { sva } from "@/styled-system/css";

export const buttonStyles = sva({
slots: ["root"],
base: {
root: {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontWeight: "medium",
fontFamily: "sans",
lineHeight: "1",
borderRadius: "md",
cursor: "pointer",
transition: "opacity 150ms, background-color 150ms",
outline: "none",
userSelect: "none",
flexShrink: "0",
border: "1px solid transparent",
},
},
variants: {
variant: {
primary: {
root: {
bg: "accent.DEFAULT",
color: "accent.fg",
},
},
outline: {
root: {
bg: "transparent",
color: "fg.DEFAULT",
borderColor: "border.strong",
},
},
ghost: {
root: {
bg: "transparent",
color: "fg.DEFAULT",
},
},
},
size: {
sm: {
root: {
h: "8",
px: "3",
gap: "1.5",
fontSize: "sm",
},
},
md: {
root: {
h: "10",
px: "4",
gap: "2",
fontSize: "md",
},
},
lg: {
root: {
h: "12",
px: "5",
gap: "2.5",
fontSize: "lg",
},
},
},
isIconOnly: {
true: {
root: {
px: "0",
borderRadius: "full",
aspectRatio: "1",
},
},
},
disabled: {
true: {
root: {
opacity: "0.5",
cursor: "not-allowed",
},
},
},
},
defaultVariants: {
variant: "primary",
size: "md",
isIconOnly: false,
disabled: false,
},
});
60 changes: 60 additions & 0 deletions src/components/base/button/button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";

vi.mock("../../icon/icon", () => ({
Icon: ({ name }: { name: string }) => <span data-testid={`icon-${name}`} />,
}));

describe("Button", () => {
it("renders children", () => {
render(<Button>Click me</Button>);
expect(screen.getByText("Click me")).toBeTruthy();
});

it("disabled state prevents click", async () => {
const onClick = vi.fn();
render(
<Button disabled onClick={onClick}>
Click
</Button>,
);
await userEvent.click(screen.getByRole("button"));
expect(onClick).not.toHaveBeenCalled();
});

it("isLoading renders a spinner icon", () => {
render(<Button isLoading>Submit</Button>);
expect(screen.getByTestId("icon-Refresh")).toBeTruthy();
});

it("isLoading disables interaction", async () => {
const onClick = vi.fn();
render(
<Button isLoading onClick={onClick}>
Submit
</Button>,
);
await userEvent.click(screen.getByRole("button"));
expect(onClick).not.toHaveBeenCalled();
});

it.each([
"primary",
"ghost",
"outline",
] as const)("%s variant renders without error", (variant) => {
render(<Button variant={variant}>{variant}</Button>);
expect(screen.getByText(variant)).toBeTruthy();
});

it.each([
"sm",
"md",
"lg",
] as const)("%s size renders without error", (size) => {
render(<Button size={size}>{size}</Button>);
expect(screen.getByText(size)).toBeTruthy();
});
});
Loading