refactor(ui): convert Icon and Badge components to JSX syntax

- Convert Icon component from createElement to JSX (Icon.tsx)
- Convert Icon tests to JSX (24 tests passing)
- Convert Icon stories to JSX (Icon.stories.tsx)
- Convert Badge component from createElement to JSX (Badge.tsx)
- Convert Badge tests to JSX (34 tests passing)
- Rename files from .ts to .tsx
- Remove createElement imports, use native JSX syntax
- All tests passing (58/58)

Part of broader effort to convert entire codebase from createElement to JSX.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2025-11-26 02:10:18 +10:00
parent 9a8c0beb85
commit b17349c071
5 changed files with 254 additions and 392 deletions

View file

@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Icon } from "@tpmjs/ui/Icon/Icon";
const meta = {
title: "Components/Icon",
component: Icon,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
} satisfies Meta<typeof Icon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AllIcons: Story = {
render: () => (
<div className="grid grid-cols-4 gap-8 p-8">
{(
[
"copy",
"externalLink",
"github",
"check",
"x",
"chevronDown",
"sun",
"moon",
] as const
).map((iconName) => (
<div key={iconName} className="flex flex-col items-center gap-2">
<Icon icon={iconName} size="lg" />
<span className="text-sm text-foreground-secondary">{iconName}</span>
</div>
))}
</div>
),
};
export const AllSizes: Story = {
render: () => (
<div className="flex items-center gap-6">
{(["sm", "md", "lg"] as const).map((size) => (
<div key={size} className="flex flex-col items-center gap-2">
<Icon icon="github" size={size} />
<span className="text-sm text-foreground-secondary">{size}</span>
</div>
))}
</div>
),
};
export const WithColors: Story = {
render: () => (
<div className="flex items-center gap-6">
<Icon icon="check" size="lg" className="text-success" />
<Icon icon="x" size="lg" className="text-error" />
<Icon icon="github" size="lg" className="text-info" />
<Icon icon="sun" size="lg" className="text-warning" />
</div>
),
};

View file

@ -1,19 +1,18 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Badge } from "./Badge"; import { Badge } from "./Badge";
describe("Badge", () => { describe("Badge", () => {
describe("Rendering", () => { describe("Rendering", () => {
it("renders a badge element", () => { it("renders a badge element", () => {
render(createElement(Badge, { "data-testid": "badge" }, "Badge")); render(<Badge data-testid="badge">Badge</Badge>);
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge).toBeInTheDocument(); expect(badge).toBeInTheDocument();
expect(badge.tagName).toBe("DIV"); expect(badge.tagName).toBe("DIV");
}); });
it("renders children text", () => { it("renders children text", () => {
render(createElement(Badge, null, "Active")); render(<Badge>Active</Badge>);
expect(screen.getByText("Active")).toBeInTheDocument(); expect(screen.getByText("Active")).toBeInTheDocument();
}); });
}); });
@ -21,14 +20,9 @@ describe("Badge", () => {
describe("Variants", () => { describe("Variants", () => {
it("applies default variant classes", () => { it("applies default variant classes", () => {
render( render(
createElement( <Badge variant="default" data-testid="badge">
Badge, Default
{ </Badge>,
variant: "default",
"data-testid": "badge",
},
"Default",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-primary"); expect(badge.className).toContain("bg-primary");
@ -38,14 +32,9 @@ describe("Badge", () => {
it("applies secondary variant classes", () => { it("applies secondary variant classes", () => {
render( render(
createElement( <Badge variant="secondary" data-testid="badge">
Badge, Secondary
{ </Badge>,
variant: "secondary",
"data-testid": "badge",
},
"Secondary",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-secondary"); expect(badge.className).toContain("bg-secondary");
@ -54,14 +43,9 @@ describe("Badge", () => {
it("applies outline variant classes", () => { it("applies outline variant classes", () => {
render( render(
createElement( <Badge variant="outline" data-testid="badge">
Badge, Outline
{ </Badge>,
variant: "outline",
"data-testid": "badge",
},
"Outline",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-transparent"); expect(badge.className).toContain("bg-transparent");
@ -71,14 +55,9 @@ describe("Badge", () => {
it("applies success variant classes", () => { it("applies success variant classes", () => {
render( render(
createElement( <Badge variant="success" data-testid="badge">
Badge, Success
{ </Badge>,
variant: "success",
"data-testid": "badge",
},
"Success",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-success"); expect(badge.className).toContain("bg-success");
@ -87,14 +66,9 @@ describe("Badge", () => {
it("applies error variant classes", () => { it("applies error variant classes", () => {
render( render(
createElement( <Badge variant="error" data-testid="badge">
Badge, Error
{ </Badge>,
variant: "error",
"data-testid": "badge",
},
"Error",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-error"); expect(badge.className).toContain("bg-error");
@ -103,14 +77,9 @@ describe("Badge", () => {
it("applies warning variant classes", () => { it("applies warning variant classes", () => {
render( render(
createElement( <Badge variant="warning" data-testid="badge">
Badge, Warning
{ </Badge>,
variant: "warning",
"data-testid": "badge",
},
"Warning",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-warning"); expect(badge.className).toContain("bg-warning");
@ -119,14 +88,9 @@ describe("Badge", () => {
it("applies info variant classes", () => { it("applies info variant classes", () => {
render( render(
createElement( <Badge variant="info" data-testid="badge">
Badge, Info
{ </Badge>,
variant: "info",
"data-testid": "badge",
},
"Info",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-info"); expect(badge.className).toContain("bg-info");
@ -137,14 +101,9 @@ describe("Badge", () => {
describe("Sizes", () => { describe("Sizes", () => {
it("applies small size classes", () => { it("applies small size classes", () => {
render( render(
createElement( <Badge size="sm" data-testid="badge">
Badge, Small
{ </Badge>,
size: "sm",
"data-testid": "badge",
},
"Small",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("px-2"); expect(badge.className).toContain("px-2");
@ -154,14 +113,9 @@ describe("Badge", () => {
it("applies medium size classes (default)", () => { it("applies medium size classes (default)", () => {
render( render(
createElement( <Badge size="md" data-testid="badge">
Badge, Medium
{ </Badge>,
size: "md",
"data-testid": "badge",
},
"Medium",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("px-2.5"); expect(badge.className).toContain("px-2.5");
@ -171,14 +125,9 @@ describe("Badge", () => {
it("applies large size classes", () => { it("applies large size classes", () => {
render( render(
createElement( <Badge size="lg" data-testid="badge">
Badge, Large
{ </Badge>,
size: "lg",
"data-testid": "badge",
},
"Large",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("px-3"); expect(badge.className).toContain("px-3");
@ -187,15 +136,7 @@ describe("Badge", () => {
}); });
it("uses medium size by default", () => { it("uses medium size by default", () => {
render( render(<Badge data-testid="badge">Default</Badge>);
createElement(
Badge,
{
"data-testid": "badge",
},
"Default",
),
);
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("text-sm"); expect(badge.className).toContain("text-sm");
}); });
@ -204,14 +145,9 @@ describe("Badge", () => {
describe("HTML Attributes", () => { describe("HTML Attributes", () => {
it("passes through id attribute", () => { it("passes through id attribute", () => {
render( render(
createElement( <Badge id="badge-id" data-testid="badge">
Badge, Badge
{ </Badge>,
id: "badge-id",
"data-testid": "badge",
},
"Badge",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("id", "badge-id"); expect(badge).toHaveAttribute("id", "badge-id");
@ -219,14 +155,9 @@ describe("Badge", () => {
it("passes through title attribute", () => { it("passes through title attribute", () => {
render( render(
createElement( <Badge title="Badge tooltip" data-testid="badge">
Badge, Badge
{ </Badge>,
title: "Badge tooltip",
"data-testid": "badge",
},
"Badge",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("title", "Badge tooltip"); expect(badge).toHaveAttribute("title", "Badge tooltip");
@ -235,16 +166,14 @@ describe("Badge", () => {
it("passes through onClick handler", () => { it("passes through onClick handler", () => {
let clicked = false; let clicked = false;
render( render(
createElement( <Badge
Badge, onClick={() => {
{ clicked = true;
onClick: () => { }}
clicked = true; data-testid="badge"
}, >
"data-testid": "badge", Clickable
}, </Badge>,
"Clickable",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
badge.click(); badge.click();
@ -253,14 +182,9 @@ describe("Badge", () => {
it("passes through role attribute", () => { it("passes through role attribute", () => {
render( render(
createElement( <Badge role="status" data-testid="badge">
Badge, Status
{ </Badge>,
role: "status",
"data-testid": "badge",
},
"Status",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("role", "status"); expect(badge).toHaveAttribute("role", "status");
@ -270,14 +194,9 @@ describe("Badge", () => {
describe("ARIA Attributes", () => { describe("ARIA Attributes", () => {
it("passes through aria-label", () => { it("passes through aria-label", () => {
render( render(
createElement( <Badge aria-label="Status badge" data-testid="badge">
Badge, Active
{ </Badge>,
"aria-label": "Status badge",
"data-testid": "badge",
},
"Active",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("aria-label", "Status badge"); expect(badge).toHaveAttribute("aria-label", "Status badge");
@ -285,14 +204,9 @@ describe("Badge", () => {
it("passes through aria-describedby", () => { it("passes through aria-describedby", () => {
render( render(
createElement( <Badge aria-describedby="description-id" data-testid="badge">
Badge, Badge
{ </Badge>,
"aria-describedby": "description-id",
"data-testid": "badge",
},
"Badge",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("aria-describedby", "description-id"); expect(badge).toHaveAttribute("aria-describedby", "description-id");
@ -300,14 +214,9 @@ describe("Badge", () => {
it("passes through aria-live for dynamic status", () => { it("passes through aria-live for dynamic status", () => {
render( render(
createElement( <Badge aria-live="polite" data-testid="badge">
Badge, Updating
{ </Badge>,
"aria-live": "polite",
"data-testid": "badge",
},
"Updating",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("aria-live", "polite"); expect(badge).toHaveAttribute("aria-live", "polite");
@ -317,14 +226,9 @@ describe("Badge", () => {
describe("Custom className", () => { describe("Custom className", () => {
it("merges custom className with variant classes", () => { it("merges custom className with variant classes", () => {
render( render(
createElement( <Badge className="custom-badge" data-testid="badge">
Badge, Custom
{ </Badge>,
className: "custom-badge",
"data-testid": "badge",
},
"Custom",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("custom-badge"); expect(badge.className).toContain("custom-badge");
@ -337,15 +241,13 @@ describe("Badge", () => {
it("forwards ref to badge element", () => { it("forwards ref to badge element", () => {
let ref: HTMLDivElement | null = null; let ref: HTMLDivElement | null = null;
render( render(
createElement( <Badge
Badge, ref={(el: HTMLDivElement | null) => {
{ ref = el;
ref: (el: HTMLDivElement | null) => { }}
ref = el; >
}, Badge
}, </Badge>,
"Badge",
),
); );
expect(ref).toBeInstanceOf(HTMLDivElement); expect(ref).toBeInstanceOf(HTMLDivElement);
expect(ref?.tagName).toBe("DIV"); expect(ref?.tagName).toBe("DIV");
@ -354,15 +256,7 @@ describe("Badge", () => {
describe("Base Classes", () => { describe("Base Classes", () => {
it("always includes base classes", () => { it("always includes base classes", () => {
render( render(<Badge data-testid="badge">Badge</Badge>);
createElement(
Badge,
{
"data-testid": "badge",
},
"Badge",
),
);
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("inline-flex"); expect(badge.className).toContain("inline-flex");
expect(badge.className).toContain("items-center"); expect(badge.className).toContain("items-center");
@ -372,15 +266,7 @@ describe("Badge", () => {
}); });
it("includes transition classes", () => { it("includes transition classes", () => {
render( render(<Badge data-testid="badge">Badge</Badge>);
createElement(
Badge,
{
"data-testid": "badge",
},
"Badge",
),
);
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("transition-base"); expect(badge.className).toContain("transition-base");
}); });
@ -389,15 +275,9 @@ describe("Badge", () => {
describe("Compound Scenarios", () => { describe("Compound Scenarios", () => {
it("works correctly with success variant and small size", () => { it("works correctly with success variant and small size", () => {
render( render(
createElement( <Badge variant="success" size="sm" data-testid="badge">
Badge, Success
{ </Badge>,
variant: "success",
size: "sm",
"data-testid": "badge",
},
"Success",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-success"); expect(badge.className).toContain("bg-success");
@ -406,15 +286,9 @@ describe("Badge", () => {
it("works correctly with error variant and large size", () => { it("works correctly with error variant and large size", () => {
render( render(
createElement( <Badge variant="error" size="lg" data-testid="badge">
Badge, Error
{ </Badge>,
variant: "error",
size: "lg",
"data-testid": "badge",
},
"Error",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-error"); expect(badge.className).toContain("bg-error");
@ -423,15 +297,13 @@ describe("Badge", () => {
it("works correctly with outline variant and custom className", () => { it("works correctly with outline variant and custom className", () => {
render( render(
createElement( <Badge
Badge, variant="outline"
{ className="hover:bg-accent"
variant: "outline", data-testid="badge"
className: "hover:bg-accent", >
"data-testid": "badge", Outlined
}, </Badge>,
"Outlined",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-transparent"); expect(badge.className).toContain("bg-transparent");
@ -441,17 +313,15 @@ describe("Badge", () => {
it("works correctly with warning variant and onClick", () => { it("works correctly with warning variant and onClick", () => {
let clicked = false; let clicked = false;
render( render(
createElement( <Badge
Badge, variant="warning"
{ onClick={() => {
variant: "warning", clicked = true;
onClick: () => { }}
clicked = true; data-testid="badge"
}, >
"data-testid": "badge", Warning
}, </Badge>,
"Warning",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-warning"); expect(badge.className).toContain("bg-warning");
@ -462,23 +332,20 @@ describe("Badge", () => {
describe("Content Types", () => { describe("Content Types", () => {
it("renders with text content", () => { it("renders with text content", () => {
render(createElement(Badge, null, "Text")); render(<Badge>Text</Badge>);
expect(screen.getByText("Text")).toBeInTheDocument(); expect(screen.getByText("Text")).toBeInTheDocument();
}); });
it("renders with numeric content", () => { it("renders with numeric content", () => {
render(createElement(Badge, null, "42")); render(<Badge>42</Badge>);
expect(screen.getByText("42")).toBeInTheDocument(); expect(screen.getByText("42")).toBeInTheDocument();
}); });
it("renders with multiple children", () => { it("renders with multiple children", () => {
render( render(
createElement( <Badge>
Badge, Count: <strong>5</strong>
null, </Badge>,
"Count: ",
createElement("strong", null, "5"),
),
); );
expect(screen.getByText("Count:")).toBeInTheDocument(); expect(screen.getByText("Count:")).toBeInTheDocument();
expect(screen.getByText("5")).toBeInTheDocument(); expect(screen.getByText("5")).toBeInTheDocument();
@ -488,16 +355,14 @@ describe("Badge", () => {
describe("Semantic Usage", () => { describe("Semantic Usage", () => {
it("can be used as status indicator with role", () => { it("can be used as status indicator with role", () => {
render( render(
createElement( <Badge
Badge, variant="success"
{ role="status"
variant: "success", aria-label="Online status"
role: "status", data-testid="badge"
"aria-label": "Online status", >
"data-testid": "badge", Online
}, </Badge>,
"Online",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("role", "status"); expect(badge).toHaveAttribute("role", "status");
@ -507,16 +372,14 @@ describe("Badge", () => {
it("can be used as notification count", () => { it("can be used as notification count", () => {
render( render(
createElement( <Badge
Badge, variant="error"
{ size="sm"
variant: "error", aria-label="3 unread messages"
size: "sm", data-testid="badge"
"aria-label": "3 unread messages", >
"data-testid": "badge", 3
}, </Badge>,
"3",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("aria-label", "3 unread messages"); expect(badge).toHaveAttribute("aria-label", "3 unread messages");
@ -525,14 +388,9 @@ describe("Badge", () => {
it("can be used as tag/label", () => { it("can be used as tag/label", () => {
render( render(
createElement( <Badge variant="outline" data-testid="badge">
Badge, TypeScript
{ </Badge>,
variant: "outline",
"data-testid": "badge",
},
"TypeScript",
),
); );
const badge = screen.getByTestId("badge"); const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-transparent"); expect(badge.className).toContain("bg-transparent");

View file

@ -1,5 +1,5 @@
import { cn } from "@tpmjs/utils/cn"; import { cn } from "@tpmjs/utils/cn";
import { createElement, forwardRef } from "react"; import { forwardRef } from "react";
import type { BadgeProps } from "./types"; import type { BadgeProps } from "./types";
import { badgeVariants } from "./variants"; import { badgeVariants } from "./variants";
@ -7,34 +7,31 @@ import { badgeVariants } from "./variants";
* Badge component * Badge component
* *
* A versatile badge component for status indicators, tags, and labels. * A versatile badge component for status indicators, tags, and labels.
* Built with .ts-only React using createElement.
* *
* @example * @example
* ```typescript * ```tsx
* import { Badge } from '@tpmjs/ui/Badge/Badge'; * import { Badge } from '@tpmjs/ui/Badge/Badge';
* import { createElement } from 'react';
* *
* function MyComponent() { * function MyComponent() {
* return createElement(Badge, { * return <Badge variant="success">Active</Badge>;
* variant: 'success',
* children: 'Active',
* });
* } * }
* ``` * ```
*/ */
export const Badge = forwardRef<HTMLDivElement, BadgeProps>( export const Badge = forwardRef<HTMLDivElement, BadgeProps>(
({ className, variant = "default", size = "md", ...props }, ref) => { ({ className, variant = "default", size = "md", ...props }, ref) => {
return createElement("div", { return (
className: cn( <div
badgeVariants({ ref={ref}
variant, className={cn(
size, badgeVariants({
}), variant,
className, size,
), }),
ref, className,
...props, )}
}); {...props}
/>
);
}, },
); );

View file

@ -1,38 +1,37 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
describe("Icon", () => { describe("Icon", () => {
describe("Rendering", () => { describe("Rendering", () => {
it("renders an SVG element", () => { it("renders an SVG element", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" })); render(<Icon icon="check" data-testid="icon" />);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon).toBeInTheDocument(); expect(icon).toBeInTheDocument();
expect(icon.tagName).toBe("svg"); expect(icon.tagName).toBe("svg");
}); });
it("renders with correct viewBox", () => { it("renders with correct viewBox", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" })); render(<Icon icon="check" data-testid="icon" />);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon).toHaveAttribute("viewBox", "0 0 24 24"); expect(icon).toHaveAttribute("viewBox", "0 0 24 24");
}); });
it("renders with currentColor fill", () => { it("renders with currentColor fill", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" })); render(<Icon icon="check" data-testid="icon" />);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon).toHaveAttribute("fill", "currentColor"); expect(icon).toHaveAttribute("fill", "currentColor");
}); });
it("includes path element", () => { it("includes path element", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" })); render(<Icon icon="check" data-testid="icon" />);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
const path = icon.querySelector("path"); const path = icon.querySelector("path");
expect(path).toBeInTheDocument(); expect(path).toBeInTheDocument();
}); });
it("is aria-hidden by default", () => { it("is aria-hidden by default", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" })); render(<Icon icon="check" data-testid="icon" />);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon).toHaveAttribute("aria-hidden", "true"); expect(icon).toHaveAttribute("aria-hidden", "true");
}); });
@ -40,46 +39,28 @@ describe("Icon", () => {
describe("Size Variants", () => { describe("Size Variants", () => {
it("applies small size", () => { it("applies small size", () => {
render( render(<Icon icon="check" size="sm" data-testid="icon" />);
createElement(Icon, {
icon: "check",
size: "sm",
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon.className).toContain("w-4"); expect(icon.className).toContain("w-4");
expect(icon.className).toContain("h-4"); expect(icon.className).toContain("h-4");
}); });
it("applies medium size", () => { it("applies medium size", () => {
render( render(<Icon icon="check" size="md" data-testid="icon" />);
createElement(Icon, {
icon: "check",
size: "md",
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon.className).toContain("w-5"); expect(icon.className).toContain("w-5");
expect(icon.className).toContain("h-5"); expect(icon.className).toContain("h-5");
}); });
it("applies large size", () => { it("applies large size", () => {
render( render(<Icon icon="check" size="lg" data-testid="icon" />);
createElement(Icon, {
icon: "check",
size: "lg",
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon.className).toContain("w-6"); expect(icon.className).toContain("w-6");
expect(icon.className).toContain("h-6"); expect(icon.className).toContain("h-6");
}); });
it("uses md size by default", () => { it("uses md size by default", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" })); render(<Icon icon="check" data-testid="icon" />);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon.className).toContain("w-5"); expect(icon.className).toContain("w-5");
expect(icon.className).toContain("h-5"); expect(icon.className).toContain("h-5");
@ -88,7 +69,7 @@ describe("Icon", () => {
describe("Icon Variants", () => { describe("Icon Variants", () => {
it("renders copy icon", () => { it("renders copy icon", () => {
render(createElement(Icon, { icon: "copy", "data-testid": "icon" })); render(<Icon icon="copy" data-testid="icon" />);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
const path = icon.querySelector("path"); const path = icon.querySelector("path");
expect(path).toHaveAttribute( expect(path).toHaveAttribute(
@ -98,9 +79,7 @@ describe("Icon", () => {
}); });
it("renders externalLink icon", () => { it("renders externalLink icon", () => {
render( render(<Icon icon="externalLink" data-testid="icon" />);
createElement(Icon, { icon: "externalLink", "data-testid": "icon" }),
);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
const path = icon.querySelector("path"); const path = icon.querySelector("path");
expect(path).toHaveAttribute( expect(path).toHaveAttribute(
@ -110,14 +89,14 @@ describe("Icon", () => {
}); });
it("renders github icon", () => { it("renders github icon", () => {
render(createElement(Icon, { icon: "github", "data-testid": "icon" })); render(<Icon icon="github" data-testid="icon" />);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
const path = icon.querySelector("path"); const path = icon.querySelector("path");
expect(path?.getAttribute("d")).toContain("M12 2C6.477 2 2 6.477 2 12"); expect(path?.getAttribute("d")).toContain("M12 2C6.477 2 2 6.477 2 12");
}); });
it("renders check icon", () => { it("renders check icon", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" })); render(<Icon icon="check" data-testid="icon" />);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
const path = icon.querySelector("path"); const path = icon.querySelector("path");
expect(path).toHaveAttribute( expect(path).toHaveAttribute(
@ -127,7 +106,7 @@ describe("Icon", () => {
}); });
it("renders x icon", () => { it("renders x icon", () => {
render(createElement(Icon, { icon: "x", "data-testid": "icon" })); render(<Icon icon="x" data-testid="icon" />);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
const path = icon.querySelector("path"); const path = icon.querySelector("path");
expect(path).toHaveAttribute( expect(path).toHaveAttribute(
@ -137,9 +116,7 @@ describe("Icon", () => {
}); });
it("renders chevronDown icon", () => { it("renders chevronDown icon", () => {
render( render(<Icon icon="chevronDown" data-testid="icon" />);
createElement(Icon, { icon: "chevronDown", "data-testid": "icon" }),
);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
const path = icon.querySelector("path"); const path = icon.querySelector("path");
expect(path).toHaveAttribute( expect(path).toHaveAttribute(
@ -151,25 +128,13 @@ describe("Icon", () => {
describe("SVG Attributes", () => { describe("SVG Attributes", () => {
it("passes through className", () => { it("passes through className", () => {
render( render(<Icon icon="check" className="custom-class" data-testid="icon" />);
createElement(Icon, {
icon: "check",
className: "custom-class",
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon.className).toContain("custom-class"); expect(icon.className).toContain("custom-class");
}); });
it("merges custom className with variant classes", () => { it("merges custom className with variant classes", () => {
render( render(<Icon icon="check" className="text-red-500" data-testid="icon" />);
createElement(Icon, {
icon: "check",
className: "text-red-500",
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon.className).toContain("text-red-500"); expect(icon.className).toContain("text-red-500");
expect(icon.className).toContain("inline-block"); expect(icon.className).toContain("inline-block");
@ -177,38 +142,26 @@ describe("Icon", () => {
}); });
it("allows custom aria-hidden", () => { it("allows custom aria-hidden", () => {
render( render(<Icon icon="check" aria-hidden={false} data-testid="icon" />);
createElement(Icon, {
icon: "check",
"aria-hidden": false,
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon).toHaveAttribute("aria-hidden", "false"); expect(icon).toHaveAttribute("aria-hidden", "false");
}); });
it("passes through aria-label", () => { it("passes through aria-label", () => {
render( render(
createElement(Icon, { <Icon
icon: "check", icon="check"
"aria-label": "Success", aria-label="Success"
"aria-hidden": false, aria-hidden={false}
"data-testid": "icon", data-testid="icon"
}), />,
); );
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon).toHaveAttribute("aria-label", "Success"); expect(icon).toHaveAttribute("aria-label", "Success");
}); });
it("passes through id attribute", () => { it("passes through id attribute", () => {
render( render(<Icon icon="check" id="icon-id" data-testid="icon" />);
createElement(Icon, {
icon: "check",
id: "icon-id",
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon).toHaveAttribute("id", "icon-id"); expect(icon).toHaveAttribute("id", "icon-id");
}); });
@ -218,12 +171,12 @@ describe("Icon", () => {
it("forwards ref to SVG element", () => { it("forwards ref to SVG element", () => {
let ref: SVGSVGElement | null = null; let ref: SVGSVGElement | null = null;
render( render(
createElement(Icon, { <Icon
icon: "check", icon="check"
ref: (el: SVGSVGElement | null) => { ref={(el: SVGSVGElement | null) => {
ref = el; ref = el;
}, }}
}), />,
); );
expect(ref).toBeInstanceOf(SVGSVGElement); expect(ref).toBeInstanceOf(SVGSVGElement);
expect(ref?.tagName).toBe("svg"); expect(ref?.tagName).toBe("svg");
@ -232,7 +185,7 @@ describe("Icon", () => {
describe("Base Classes", () => { describe("Base Classes", () => {
it("always includes base classes", () => { it("always includes base classes", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" })); render(<Icon icon="check" data-testid="icon" />);
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon.className).toContain("inline-block"); expect(icon.className).toContain("inline-block");
expect(icon.className).toContain("fill-current"); expect(icon.className).toContain("fill-current");
@ -242,12 +195,12 @@ describe("Icon", () => {
describe("Compound Scenarios", () => { describe("Compound Scenarios", () => {
it("works correctly with custom size and className", () => { it("works correctly with custom size and className", () => {
render( render(
createElement(Icon, { <Icon
icon: "github", icon="github"
size: "lg", size="lg"
className: "text-zinc-400 hover:text-zinc-100", className="text-zinc-400 hover:text-zinc-100"
"data-testid": "icon", data-testid="icon"
}), />,
); );
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon.className).toContain("w-6"); expect(icon.className).toContain("w-6");
@ -258,13 +211,13 @@ describe("Icon", () => {
it("works correctly with aria attributes and custom size", () => { it("works correctly with aria attributes and custom size", () => {
render( render(
createElement(Icon, { <Icon
icon: "externalLink", icon="externalLink"
size: "sm", size="sm"
"aria-label": "Opens in new tab", aria-label="Opens in new tab"
"aria-hidden": false, aria-hidden={false}
"data-testid": "icon", data-testid="icon"
}), />,
); );
const icon = screen.getByTestId("icon"); const icon = screen.getByTestId("icon");
expect(icon.className).toContain("w-4"); expect(icon.className).toContain("w-4");

View file

@ -1,5 +1,5 @@
import { cn } from "@tpmjs/utils/cn"; import { cn } from "@tpmjs/utils/cn";
import { createElement, forwardRef } from "react"; import { forwardRef } from "react";
import { type IconName, icons } from "./icons"; import { type IconName, icons } from "./icons";
import type { IconProps } from "./types"; import type { IconProps } from "./types";
import { iconVariants } from "./variants"; import { iconVariants } from "./variants";
@ -11,19 +11,13 @@ export type { IconName };
* *
* Renders SVG icons with consistent sizing and styling. * Renders SVG icons with consistent sizing and styling.
* Icons inherit text color via currentColor. * Icons inherit text color via currentColor.
* Built with .ts-only React using createElement.
* *
* @example * @example
* ```typescript * ```tsx
* import { Icon } from '@tpmjs/ui/Icon/Icon'; * import { Icon } from '@tpmjs/ui/Icon/Icon';
* import { createElement } from 'react';
* *
* function MyComponent() { * function MyComponent() {
* return createElement(Icon, { * return <Icon icon="github" size="md" className="text-zinc-400" />;
* icon: 'github',
* size: 'md',
* className: 'text-zinc-400',
* });
* } * }
* ``` * ```
*/ */
@ -31,24 +25,22 @@ export const Icon = forwardRef<SVGSVGElement, IconProps>(
({ className, icon, size = "md", ...props }, ref) => { ({ className, icon, size = "md", ...props }, ref) => {
const iconData = icons[icon]; const iconData = icons[icon];
return createElement( return (
"svg", <svg
{ ref={ref}
className: cn( className={cn(
iconVariants({ iconVariants({
size, size,
}), }),
className, className,
), )}
viewBox: iconData.viewBox, viewBox={iconData.viewBox}
fill: "currentColor", fill="currentColor"
"aria-hidden": props["aria-hidden"] ?? true, aria-hidden={props["aria-hidden"] ?? true}
ref, {...props}
...props, >
}, <path d={iconData.path} />
createElement("path", { </svg>
d: iconData.path,
}),
); );
}, },
); );