feat: add light/dark theme system and full homepage with comprehensive UI components

Implement theme system:
- Add next-themes for theme management with light mode as default
- Swap CSS variable definitions in globals.css (light in :root, dark in .dark class)
- Create ThemeProvider component wrapping next-themes
- Create ThemeToggle component with sun/moon icons
- Update root layout with ThemeProvider

Build full homepage:
- Add Header component with navigation and theme toggle
- Create hero section with gradient background and search
- Add Featured Tools section with 6 tool cards (icons, badges, usage stats)
- Add Browse by Category section with 12 colored category tiles
- Add Platform Statistics section with 4 metric cards
- Add footer with copyright and links
- Create homePageData.ts with comprehensive mock data

Add Storybook stories:
- Icon.stories.ts (AllIcons, AllSizes, WithColors)
- Badge.stories.ts (Default, AllVariants, AllSizes, WithLongText)
- Input.stories.ts (Default, WithLabel, AllStates, AllSizes, PasswordInput)
- ProgressBar.stories.ts (Default, AllVariants, AllSizes, WithLabel, edge cases)
- CodeBlock.stories.ts (Default, multiple languages, LongCode)
- Tabs.stories.ts (Default, AllSizes, WithCounts, ManyTabs)

Fix production build issues:
- Export IconName type from Icon.ts
- Add sun and moon icons to icons.ts
- Fix Icon prop naming (icon not name)
- Fix Icon size constraints (remove xl, use lg)
- Update ThemeProvider types using ComponentProps
- Add explicit return types where needed
- Remove unused imports
- Replace forEach with for...of in variants system
- Fix template literals in storybook stories

🤖 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 01:29:28 +10:00
parent aaf6d788c3
commit a6753cd8e6
84 changed files with 10894 additions and 313 deletions

View file

@ -1,36 +1,37 @@
{
"name": "@tpmjs/web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"clean": "rm -rf .next .turbo"
},
"dependencies": {
"@tpmjs/env": "workspace:*",
"@tpmjs/types": "workspace:*",
"@tpmjs/ui": "workspace:*",
"@tpmjs/utils": "workspace:*",
"next": "^16.0.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@tpmjs/eslint-config": "workspace:*",
"@tpmjs/tailwind-config": "workspace:*",
"@tpmjs/tsconfig": "workspace:*",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"eslint-config-next": "^16.0.4",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.9.3"
}
"name": "@tpmjs/web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"clean": "rm -rf .next .turbo"
},
"dependencies": {
"@tpmjs/env": "workspace:*",
"@tpmjs/types": "workspace:*",
"@tpmjs/ui": "workspace:*",
"@tpmjs/utils": "workspace:*",
"next": "^16.0.4",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@tpmjs/eslint-config": "workspace:*",
"@tpmjs/tailwind-config": "workspace:*",
"@tpmjs/tsconfig": "workspace:*",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"eslint-config-next": "^16.0.4",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.9.3"
}
}

View file

@ -3,28 +3,285 @@
@tailwind utilities;
@layer base {
/* Light mode (default) */
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
/* Backgrounds & Surfaces */
--background: 0 0% 100%; /* Pure white */
--surface: 210 20% 98%; /* Off-white */
--surface-elevated: 0 0% 100%; /* White (elevated) */
--surface-overlay: 0 0% 98%; /* Light gray */
/* Foreground (Text) */
--foreground: 222 47% 11%; /* Almost black */
--foreground-secondary: 215 16% 47%; /* Medium gray */
--foreground-tertiary: 215 16% 65%; /* Light gray */
--foreground-muted: 215 16% 75%; /* Very light gray */
/* Borders */
--border: 214 32% 91%; /* Light gray */
--border-strong: 214 32% 80%; /* Medium gray */
--border-subtle: 214 20% 95%; /* Very light gray */
/* Interactive States */
--primary: 222 47% 11%; /* Dark for light mode */
--primary-foreground: 210 40% 98%; /* Light text */
--secondary: 210 40% 96%; /* Light secondary */
--secondary-foreground: 222 47% 11%; /* Dark text */
--accent: 210 40% 96%; /* Accent bg */
--accent-foreground: 222 47% 11%; /* Accent text */
--muted: 210 40% 96%; /* Muted bg */
--muted-foreground: 215 16% 47%; /* Muted text */
/* Status Colors */
--success: 142 71% 45%;
--success-foreground: 142 76% 15%;
--error: 0 72% 51%;
--error-foreground: 0 86% 17%;
--warning: 38 92% 50%;
--warning-foreground: 48 96% 19%;
--info: 217 91% 60%;
--info-foreground: 214 95% 23%;
/* Destructive (legacy) */
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
/* Form Elements */
--input: 214 32% 91%;
--ring: 222 84% 5%;
--ring-offset: 0 0% 100%;
/* Grid/Blueprint Pattern */
--grid-color: 214 32% 95%;
--grid-size: 24px; /* Grid cell size */
/* Card */
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
/* Border Radii */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius: var(--radius-md);
/* Typography */
--font-sans: 'Geist', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--font-mono: 'Geist Mono', 'SF Mono', Consolas, monospace;
}
/* Dark mode (opt-in) - Vercel/Cursor/Perplexity aesthetic */
.dark {
/* Backgrounds & Surfaces */
--background: 210 10% 5%; /* #0d0d0f - Almost black */
--surface: 210 10% 8%; /* #14141a - Slightly lighter */
--surface-elevated: 210 10% 12%; /* #1c1c24 - Cards, modals */
--surface-overlay: 210 10% 16%; /* #25252f - Overlays, popovers */
/* Foreground (Text) */
--foreground: 210 10% 90%; /* #e3e3e8 - Primary text */
--foreground-secondary: 210 8% 60%; /* #949499 - Secondary text */
--foreground-tertiary: 210 6% 40%; /* #64646a - Tertiary text */
--foreground-muted: 210 6% 30%; /* #4a4a50 - Muted text */
/* Borders (1px, subtle) */
--border: 210 10% 20%; /* #30303a - Default border */
--border-strong: 210 12% 30%; /* #494954 - Emphasized border */
--border-subtle: 210 8% 15%; /* #22222c - Subtle border */
/* Interactive States */
--primary: 210 10% 98%; /* White-ish for dark mode */
--primary-foreground: 210 10% 5%; /* Dark text on light bg */
--secondary: 210 10% 15%; /* Dark secondary */
--secondary-foreground: 210 10% 90%; /* Light text on dark bg */
--accent: 210 10% 18%; /* Accent background */
--accent-foreground: 210 10% 95%; /* Accent text */
--muted: 210 10% 12%; /* Muted background */
--muted-foreground: 210 8% 60%; /* Muted text */
/* Status Colors (desaturated for dark mode) */
--success: 142 71% 45%; /* #10b981 - Green */
--success-foreground: 142 76% 95%; /* Light green text */
--error: 0 72% 51%; /* #ef4444 - Red */
--error-foreground: 0 86% 97%; /* Light red text */
--warning: 38 92% 50%; /* #f59e0b - Amber */
--warning-foreground: 48 96% 89%; /* Light amber text */
--info: 217 91% 60%; /* #3b82f6 - Blue */
--info-foreground: 214 95% 93%; /* Light blue text */
/* Destructive (legacy support) */
--destructive: 0 72% 51%;
--destructive-foreground: 0 86% 97%;
/* Form Elements */
--input: 210 10% 20%; /* Input border */
--ring: 210 10% 90%; /* Focus ring */
--ring-offset: 210 10% 5%; /* Focus ring offset */
/* Grid/Blueprint Pattern */
--grid-color: 210 10% 15%; /* Grid line color */
/* Card */
--card: 210 10% 8%;
--card-foreground: 210 10% 90%;
}
/* Base element styles */
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
font-feature-settings: 'rlig' 1, 'calt' 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Grid/Blueprint background patterns */
.grid-background {
background-image:
linear-gradient(hsl(var(--grid-color)) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--grid-color)) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
background-position: -1px -1px;
}
.dotted-grid-background {
background-image:
radial-gradient(circle, hsl(var(--grid-color)) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
}
.blueprint-background {
background-color: hsl(var(--background));
background-image:
/* Major grid lines (4x size) */
linear-gradient(hsl(var(--grid-color)) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--grid-color)) 1px, transparent 1px),
/* Minor grid lines (1x size) */
linear-gradient(hsl(var(--grid-color) / 0.3) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--grid-color) / 0.3) 1px, transparent 1px);
background-size:
calc(var(--grid-size) * 4) calc(var(--grid-size) * 4),
calc(var(--grid-size) * 4) calc(var(--grid-size) * 4),
var(--grid-size) var(--grid-size),
var(--grid-size) var(--grid-size);
}
/* Animated grid (subtle pulse) */
.grid-background-animated {
@apply grid-background;
animation: grid-pulse 8s ease-in-out infinite;
}
@keyframes grid-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.6; }
}
}
body {
@apply bg-background text-foreground;
@layer utilities {
/* Border utilities */
.border-1 {
border-width: 1px;
}
.border-dotted-1 {
border-width: 1px;
border-style: dotted;
}
.border-dashed-1 {
border-width: 1px;
border-style: dashed;
}
/* Directional dotted borders */
.border-t-dotted {
border-top-width: 1px;
border-top-style: dotted;
}
.border-b-dotted {
border-bottom-width: 1px;
border-bottom-style: dotted;
}
.border-l-dotted {
border-left-width: 1px;
border-left-style: dotted;
}
.border-r-dotted {
border-right-width: 1px;
border-right-style: dotted;
}
/* Transition presets */
.transition-base {
transition-property: background-color, border-color, color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
.transition-transform {
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
.transition-all-smooth {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
/* Hover effects */
.hover-lift {
@apply transition-transform hover:-translate-y-0.5 hover:shadow-md;
}
/* Focus ring (modern, accessible) */
.focus-ring {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
/* Custom animations */
@keyframes slide-up {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.animate-slide-up {
animation: slide-up 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.animate-shimmer {
animation: shimmer 2s infinite linear;
background: linear-gradient(
90deg,
hsl(var(--muted)) 0%,
hsl(var(--accent)) 50%,
hsl(var(--muted)) 100%
);
background-size: 1000px 100%;
}
}

View file

@ -1,19 +1,30 @@
import type { Metadata } from 'next';
import './globals.css';
import type { Metadata } from "next";
import { ThemeProvider } from "../components/providers/ThemeProvider";
import "./globals.css";
export const metadata: Metadata = {
title: 'TPMJS - Tool Package Manager for AI Agents',
description: 'The registry for AI tools. Discover, share, and integrate tools that give your agents superpowers.',
title: "TPMJS - Tool Package Manager for AI Agents",
description:
"The registry for AI tools. Discover, share, and integrate tools that give your agents superpowers.",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}): React.ReactElement {
return (
<html lang="en">
<body>{children}</body>
</html>
);
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem={true}
disableTransitionOnChange={false}
>
{children}
</ThemeProvider>
</body>
</html>
);
}

View file

@ -1,38 +1,319 @@
import { Button } from '@tpmjs/ui/Button/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@tpmjs/ui/Card/Card';
import { createElement } from 'react';
import { Badge } from "@tpmjs/ui/Badge/Badge";
import { Button } from "@tpmjs/ui/Button/Button";
import { Card, CardContent } from "@tpmjs/ui/Card/Card";
import { Container } from "@tpmjs/ui/Container/Container";
import { Header } from "@tpmjs/ui/Header/Header";
import { Icon } from "@tpmjs/ui/Icon/Icon";
import { Input } from "@tpmjs/ui/Input/Input";
import { createElement } from "react";
import { ThemeToggle } from "../components/ThemeToggle";
import { categories, featuredTools, statistics } from "../data/homePageData";
export default function HomePage(): React.ReactElement {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
<div className="space-y-8 max-w-2xl">
<h1 className="text-4xl font-bold text-center">TPMJS</h1>
<p className="text-xl text-center text-muted-foreground">
Tool Package Manager for AI Agents
</p>
return createElement(
"div",
{ className: "min-h-screen flex flex-col" },
// Header
createElement(Header, {
title: "TPMJS",
size: "md",
sticky: true,
actions: createElement(
"div",
{ className: "flex items-center gap-4" },
createElement(Button, { variant: "ghost", size: "sm" }, "Pro"),
createElement(Button, { variant: "ghost", size: "sm" }, "Teams"),
createElement(Button, { variant: "ghost", size: "sm" }, "Pricing"),
createElement(
Button,
{ variant: "ghost", size: "sm" },
"Documentation",
),
createElement(Button, { variant: "outline", size: "sm" }, "Sign In"),
createElement(Button, { size: "sm" }, "Sign Up"),
createElement(ThemeToggle, {}),
),
}),
{createElement(
Card,
{},
createElement(
CardHeader,
{},
createElement(CardTitle, {}, 'Welcome to TPMJS')
),
createElement(
CardContent,
{ className: 'space-y-4' },
createElement('p', {},
'The registry for AI tools. Discover, share, and integrate tools that give your agents superpowers.'
),
createElement(
Button,
{ className: 'w-full' },
'Get Started'
)
)
)}
</div>
</main>
);
createElement(
"main",
{ className: "flex-1" },
// Hero Section
createElement(
"section",
{ className: "relative py-24 overflow-hidden" },
createElement("div", {
className:
"absolute inset-0 bg-gradient-to-r from-orange-500 via-amber-500 to-red-500 opacity-10",
}),
createElement(
Container,
{ size: "xl", padding: "lg" },
createElement(
"div",
{
className:
"max-w-3xl mx-auto text-center space-y-8 relative z-10",
},
createElement(
"h1",
{ className: "text-5xl md:text-6xl font-bold tracking-tight" },
"Tool Registry for AI Agents",
),
createElement(
"p",
{ className: "text-xl text-foreground-secondary" },
"Discover, share, and integrate tools that give your agents superpowers. The registry for AI tools.",
),
createElement(
"div",
{ className: "flex gap-3 items-center max-w-2xl mx-auto" },
createElement(Input, {
size: "lg",
placeholder: "Search tools...",
className: "flex-1",
}),
createElement(
Button,
{ size: "lg", className: "px-8" },
"Search",
),
),
),
),
),
// Featured Tools Section
createElement(
"section",
{ className: "py-16 bg-surface" },
createElement(
Container,
{ size: "xl", padding: "lg" },
createElement(
"div",
{ className: "flex items-center justify-between mb-8" },
createElement(
"h2",
{ className: "text-3xl font-semibold" },
"Featured Tools",
),
createElement(Button, { variant: "ghost" }, "View all tools →"),
),
createElement(
"div",
{
className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6",
},
...featuredTools.map((tool) =>
createElement(
Card,
{
key: tool.id,
className: "hover:shadow-lg transition-shadow",
},
createElement(
CardContent,
{ className: "p-6 space-y-4" },
createElement(
"div",
{ className: "flex items-start gap-4" },
createElement(Icon, {
icon: tool.icon,
size: "lg",
className: "text-primary",
}),
createElement(
"div",
{ className: "flex-1 space-y-2" },
createElement(
"h3",
{ className: "text-lg font-semibold" },
tool.name,
),
createElement(
"p",
{ className: "text-sm text-foreground-secondary" },
tool.description,
),
createElement(
"div",
{ className: "flex items-center gap-2" },
createElement(
Badge,
{ variant: tool.categoryVariant, size: "sm" },
tool.category,
),
createElement(
"span",
{ className: "text-xs text-foreground-tertiary" },
tool.weeklyUsage,
),
),
),
),
),
),
),
),
),
),
// Categories Section
createElement(
"section",
{ className: "py-16" },
createElement(
Container,
{ size: "xl", padding: "lg" },
createElement(
"h2",
{ className: "text-3xl font-semibold mb-8" },
"Browse by Category",
),
createElement(
"div",
{
className: "grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4",
},
...categories.map((category) =>
createElement(
Card,
{
key: category.id,
className: "hover:shadow-lg transition-shadow cursor-pointer",
},
createElement(
CardContent,
{ className: `p-6 ${category.colorClass}` },
createElement(
"div",
{ className: "space-y-3" },
createElement(Icon, {
icon: category.icon,
size: "lg",
}),
createElement(
"h3",
{ className: "font-semibold" },
category.name,
),
createElement(
"p",
{ className: "text-sm opacity-90" },
`${category.toolCount} tools`,
),
),
),
),
),
),
),
),
// Statistics Section
createElement(
"section",
{ className: "py-16 bg-surface" },
createElement(
Container,
{ size: "xl", padding: "lg" },
createElement(
"h2",
{ className: "text-3xl font-semibold mb-8 text-center" },
"Platform Statistics",
),
createElement(
"div",
{
className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6",
},
...statistics.map((stat, index) =>
createElement(
Card,
{ key: index },
createElement(
CardContent,
{ className: "p-6 text-center space-y-3" },
createElement(Icon, {
icon: stat.icon,
size: "lg",
className: "mx-auto text-primary",
}),
createElement(
"div",
{ className: "text-4xl font-bold" },
stat.value,
),
createElement(
"div",
{ className: "text-sm font-medium" },
stat.label,
),
stat.subtext &&
createElement(
"div",
{ className: "text-xs text-foreground-tertiary" },
stat.subtext,
),
),
),
),
),
),
),
),
// Footer
createElement(
"footer",
{ className: "py-8 border-t border-border bg-surface" },
createElement(
Container,
{ size: "xl", padding: "lg" },
createElement(
"div",
{
className:
"flex flex-col md:flex-row items-center justify-between gap-4",
},
createElement(
"p",
{ className: "text-sm text-foreground-secondary" },
"© 2025 TPMJS. All rights reserved.",
),
createElement(
"div",
{ className: "flex items-center gap-4 text-sm" },
createElement(
"a",
{
href: "#",
className: "text-foreground-secondary hover:text-foreground",
},
"Privacy",
),
createElement("span", { className: "text-border" }, "·"),
createElement(
"a",
{
href: "#",
className: "text-foreground-secondary hover:text-foreground",
},
"Terms",
),
createElement("span", { className: "text-border" }, "·"),
createElement(
"a",
{
href: "#",
className: "text-foreground-secondary hover:text-foreground",
},
"Contact",
),
),
),
),
),
);
}

View file

@ -0,0 +1,304 @@
"use client";
import { Badge } from "@tpmjs/ui/Badge/Badge";
import { Button } from "@tpmjs/ui/Button/Button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@tpmjs/ui/Card/Card";
import { CodeBlock } from "@tpmjs/ui/CodeBlock/CodeBlock";
import { Container } from "@tpmjs/ui/Container/Container";
import { Header } from "@tpmjs/ui/Header/Header";
import { Icon } from "@tpmjs/ui/Icon/Icon";
import { Input } from "@tpmjs/ui/Input/Input";
import { ProgressBar } from "@tpmjs/ui/ProgressBar/ProgressBar";
import { Tabs } from "@tpmjs/ui/Tabs/Tabs";
import { createElement, useState } from "react";
import { getFeaturedTools, mockTools } from "../../../data/toolData";
/**
* Tool Registry Search Page
*
* Demonstrates all UI components in a realistic tool registry layout.
* Built with .ts-only React using createElement.
*/
export default function ToolSearchPage(): React.ReactElement {
const [activeTab, setActiveTab] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
// Get tools based on active tab
const displayedTools =
activeTab === "featured" ? getFeaturedTools() : mockTools;
// Filter by search query
const filteredTools = searchQuery
? displayedTools.filter(
(tool) =>
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
tool.description.toLowerCase().includes(searchQuery.toLowerCase()),
)
: displayedTools;
return createElement("div", { className: "min-h-screen bg-black" }, [
// Header
createElement(Header, {
key: "header",
title: createElement("div", { className: "flex items-center gap-2" }, [
createElement(
"span",
{ key: "title", className: "text-2xl font-bold" },
"TPMJS",
),
createElement(
Badge,
{ key: "badge", variant: "outline", size: "sm" },
"Beta",
),
]),
actions: createElement("div", { className: "flex items-center gap-3" }, [
createElement(
Button,
{ key: "docs", variant: "ghost", size: "sm" },
"Docs",
),
createElement(
"a",
{
key: "github",
href: "https://github.com/tpmjs/tpmjs",
target: "_blank",
rel: "noopener noreferrer",
className: "text-zinc-400 hover:text-zinc-100 transition-colors",
},
createElement(Icon, { icon: "github", size: "md" }),
),
createElement(
Button,
{ key: "publish", variant: "default", size: "sm" },
"Publish Tool",
),
]),
sticky: true,
size: "md",
}),
// Main content
createElement(
Container,
{ key: "container", size: "xl", padding: "md", className: "py-8" },
[
// Page header
createElement(
"div",
{ key: "page-header", className: "space-y-4 mb-8" },
[
createElement(
"h1",
{ key: "title", className: "text-4xl font-bold text-zinc-100" },
"Tool Registry",
),
createElement(
"p",
{ key: "description", className: "text-lg text-zinc-400" },
"Discover, share, and integrate tools that give your AI agents superpowers.",
),
],
),
// Search input
createElement(Input, {
key: "search",
placeholder: "Search tools...",
value: searchQuery,
onChange: (e) => setSearchQuery(e.target.value),
className: "mb-6",
}),
// Tabs
createElement(Tabs, {
key: "tabs",
tabs: [
{ id: "all", label: "All Tools", count: mockTools.length },
{
id: "featured",
label: "Featured",
count: getFeaturedTools().length,
},
],
activeTab,
onTabChange: setActiveTab,
size: "md",
className: "mb-8",
}),
// Tool grid
createElement(
"div",
{
key: "tools-grid",
className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6",
},
filteredTools.length > 0
? filteredTools.map((tool) =>
createElement(
Card,
{ key: tool.id, className: "flex flex-col" },
[
createElement(CardHeader, { key: "header" }, [
createElement(
"div",
{
key: "title-row",
className: "flex items-start justify-between gap-2",
},
[
createElement(CardTitle, { key: "title" }, tool.name),
createElement(
"a",
{
key: "link",
href: tool.repository,
target: "_blank",
rel: "noopener noreferrer",
className:
"text-zinc-400 hover:text-zinc-100 transition-colors",
},
createElement(Icon, {
icon: "externalLink",
size: "sm",
}),
),
],
),
createElement(
CardDescription,
{ key: "description" },
tool.description,
),
]),
createElement(
CardContent,
{ key: "content", className: "flex-1 space-y-4" },
[
// Category badge
createElement(
"div",
{
key: "category",
className: "flex items-center gap-2",
},
[
createElement(
Badge,
{
key: "badge",
variant: "secondary",
size: "sm",
},
tool.category,
),
createElement(
"span",
{
key: "version",
className: "text-xs text-zinc-500",
},
`v${tool.version}`,
),
],
),
// Tags
createElement(
"div",
{ key: "tags", className: "flex flex-wrap gap-2" },
tool.tags.map((tag) =>
createElement(
Badge,
{ key: tag, variant: "outline", size: "sm" },
tag,
),
),
),
// Usage progress
createElement(
"div",
{ key: "usage", className: "space-y-2" },
[
createElement(
"div",
{
key: "label",
className:
"flex items-center justify-between text-sm",
},
[
createElement(
"span",
{ key: "text", className: "text-zinc-400" },
"Usage",
),
createElement(
"span",
{ key: "stars", className: "text-zinc-500" },
`${tool.stars.toLocaleString()}`,
),
],
),
createElement(ProgressBar, {
key: "progress",
value: tool.usage,
variant:
tool.usage >= 70
? "success"
: tool.usage >= 50
? "primary"
: "warning",
size: "sm",
showLabel: true,
}),
],
),
// Install command
createElement(CodeBlock, {
key: "install",
code: tool.installCommand,
language: "bash",
size: "sm",
showCopy: true,
}),
],
),
createElement(
CardFooter,
{ key: "footer" },
createElement(
Button,
{ variant: "outline", size: "sm", className: "w-full" },
"View Details",
),
),
],
),
)
: [
createElement(
"div",
{
key: "no-results",
className: "col-span-full text-center py-12 text-zinc-500",
},
`No tools found matching "${searchQuery}"`,
),
],
),
],
),
]);
}

View file

@ -0,0 +1,45 @@
"use client";
import { Button } from "@tpmjs/ui/Button/Button";
import { Icon } from "@tpmjs/ui/Icon/Icon";
import { useTheme } from "next-themes";
import { createElement, useEffect, useState } from "react";
export function ThemeToggle(): React.ReactElement {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
// useEffect only runs on the client, so we can safely show the UI
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
// Return a placeholder with the same dimensions to avoid layout shift
return createElement(Button, {
variant: "ghost",
size: "sm",
className: "w-9 h-9 p-0",
"aria-label": "Toggle theme",
});
}
const isDark = theme === "dark";
return createElement(
Button,
{
variant: "ghost",
size: "sm",
className: "w-9 h-9 p-0",
onClick: () => setTheme(isDark ? "light" : "dark"),
"aria-label": `Switch to ${isDark ? "light" : "dark"} mode`,
title: `Switch to ${isDark ? "light" : "dark"} mode`,
},
createElement(Icon, {
icon: isDark ? "sun" : "moon",
size: "md",
className: "transition-transform duration-300",
}),
);
}

View file

@ -0,0 +1,11 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ComponentProps, createElement } from "react";
export function ThemeProvider({
children,
...props
}: ComponentProps<typeof NextThemesProvider>) {
return createElement(NextThemesProvider, props, children);
}

View file

@ -0,0 +1,230 @@
import type { IconName } from "@tpmjs/ui/Icon/Icon";
export interface ToolCard {
id: string;
name: string;
icon: IconName;
description: string;
category: string;
categoryVariant:
| "default"
| "secondary"
| "outline"
| "success"
| "error"
| "warning"
| "info";
weeklyUsage: string;
href: string;
}
export interface Category {
id: string;
name: string;
icon: IconName;
colorClass: string;
toolCount: number;
href: string;
}
export interface Statistic {
icon: IconName;
value: string;
label: string;
subtext?: string;
}
export const featuredTools: ToolCard[] = [
{
id: "tool-search",
name: "tool-search",
icon: "check",
description:
"Meta-tool that lets AI agents discover and load tools on-demand using semantic search.",
category: "Meta-Tools",
categoryVariant: "warning",
weeklyUsage: "847K/week",
href: "/tool/tool-search",
},
{
id: "web-scraper",
name: "web-scraper",
icon: "externalLink",
description:
"Extract structured data from any website with automatic schema detection and pagination support.",
category: "Web & APIs",
categoryVariant: "info",
weeklyUsage: "524K/week",
href: "/tool/web-scraper",
},
{
id: "github-manager",
name: "github-manager",
icon: "github",
description:
"Manage GitHub repositories, issues, pull requests, and workflows programmatically.",
category: "Code & Git",
categoryVariant: "default",
weeklyUsage: "392K/week",
href: "/tool/github-manager",
},
{
id: "sql-query",
name: "sql-query",
icon: "copy",
description:
"Execute SQL queries across PostgreSQL, MySQL, and SQLite databases with connection pooling.",
category: "Databases",
categoryVariant: "success",
weeklyUsage: "287K/week",
href: "/tool/sql-query",
},
{
id: "pdf-parser",
name: "pdf-parser",
icon: "copy",
description:
"Extract text, tables, and metadata from PDF documents with OCR support for scanned pages.",
category: "Documents",
categoryVariant: "outline",
weeklyUsage: "156K/week",
href: "/tool/pdf-parser",
},
{
id: "email-sender",
name: "email-sender",
icon: "check",
description:
"Send transactional emails with templates, attachments, and delivery tracking.",
category: "Email",
categoryVariant: "secondary",
weeklyUsage: "203K/week",
href: "/tool/email-sender",
},
];
export const categories: Category[] = [
{
id: "web-apis",
name: "Web & APIs",
icon: "externalLink",
colorClass: "bg-blue-500 text-white",
toolCount: 423,
href: "/category/web-apis",
},
{
id: "databases",
name: "Databases",
icon: "copy",
colorClass: "bg-emerald-500 text-white",
toolCount: 198,
href: "/category/databases",
},
{
id: "documents",
name: "Documents",
icon: "copy",
colorClass: "bg-amber-500 text-white",
toolCount: 156,
href: "/category/documents",
},
{
id: "images",
name: "Images",
icon: "check",
colorClass: "bg-pink-500 text-white",
toolCount: 134,
href: "/category/images",
},
{
id: "email",
name: "Email",
icon: "check",
colorClass: "bg-indigo-500 text-white",
toolCount: 89,
href: "/category/email",
},
{
id: "calendar",
name: "Calendar",
icon: "check",
colorClass: "bg-orange-500 text-white",
toolCount: 67,
href: "/category/calendar",
},
{
id: "search",
name: "Search",
icon: "check",
colorClass: "bg-red-500 text-white",
toolCount: 112,
href: "/category/search",
},
{
id: "code-execution",
name: "Code Execution",
icon: "github",
colorClass: "bg-zinc-700 text-white",
toolCount: 245,
href: "/category/code-execution",
},
{
id: "communication",
name: "Communication",
icon: "check",
colorClass: "bg-cyan-500 text-white",
toolCount: 178,
href: "/category/communication",
},
{
id: "analytics",
name: "Analytics",
icon: "check",
colorClass: "bg-violet-500 text-white",
toolCount: 203,
href: "/category/analytics",
},
{
id: "security",
name: "Security",
icon: "check",
colorClass: "bg-slate-600 text-white",
toolCount: 91,
href: "/category/security",
},
{
id: "workflows",
name: "Workflows",
icon: "check",
colorClass: "bg-teal-500 text-white",
toolCount: 167,
href: "/category/workflows",
},
];
export const statistics: Statistic[] = [
{
icon: "copy",
value: "2,847",
label: "Published Tools",
subtext: "Across 24 categories",
},
{
icon: "github",
value: "48K+",
label: "Active Developers",
subtext: "Building with TPMJS",
},
{
icon: "check",
value: "12M+",
label: "Weekly Invocations",
subtext: "Across all tools",
},
{
icon: "chevronDown",
value: "47ms",
label: "Average Response",
subtext: "95th percentile latency",
},
];

View file

@ -0,0 +1,238 @@
/**
* Mock data for tool registry
* Represents tools available in the TPMJS registry
*/
export interface Tool {
id: string;
name: string;
description: string;
category: string;
installCommand: string;
usage: number; // 0-100 percentage
stars: number;
version: string;
tags: string[];
homepage?: string;
repository?: string;
}
export const toolCategories = [
"Web & APIs",
"Databases",
"Documents",
"Images",
"Email",
"Calendar",
"Search",
"Code Execution",
"Communication",
"Analytics",
"Security",
"Workflows",
] as const;
export const mockTools: Tool[] = [
{
id: "web-fetch",
name: "web-fetch",
description:
"Fetch and parse web content with automatic HTML-to-markdown conversion",
category: "Web & APIs",
installCommand: "npm install @tpmjs/web-fetch",
usage: 87,
stars: 12453,
version: "2.1.4",
tags: ["http", "scraping", "markdown"],
homepage: "https://tpmjs.com/tools/web-fetch",
repository: "https://github.com/tpmjs/web-fetch",
},
{
id: "database-query",
name: "database-query",
description:
"Execute SQL queries across multiple database engines with type-safe results",
category: "Databases",
installCommand: "npm install @tpmjs/database-query",
usage: 72,
stars: 8921,
version: "3.0.1",
tags: ["sql", "postgres", "mysql", "sqlite"],
homepage: "https://tpmjs.com/tools/database-query",
repository: "https://github.com/tpmjs/database-query",
},
{
id: "pdf-extract",
name: "pdf-extract",
description:
"Extract text, images, and metadata from PDF documents with OCR support",
category: "Documents",
installCommand: "npm install @tpmjs/pdf-extract",
usage: 65,
stars: 7832,
version: "1.9.2",
tags: ["pdf", "ocr", "document-processing"],
homepage: "https://tpmjs.com/tools/pdf-extract",
repository: "https://github.com/tpmjs/pdf-extract",
},
{
id: "image-transform",
name: "image-transform",
description:
"Resize, crop, and optimize images with automatic format conversion",
category: "Images",
installCommand: "npm install @tpmjs/image-transform",
usage: 58,
stars: 6745,
version: "2.4.0",
tags: ["images", "resize", "optimization"],
homepage: "https://tpmjs.com/tools/image-transform",
repository: "https://github.com/tpmjs/image-transform",
},
{
id: "email-send",
name: "email-send",
description:
"Send transactional emails with template support and delivery tracking",
category: "Email",
installCommand: "npm install @tpmjs/email-send",
usage: 51,
stars: 5623,
version: "1.7.3",
tags: ["email", "smtp", "templates"],
homepage: "https://tpmjs.com/tools/email-send",
repository: "https://github.com/tpmjs/email-send",
},
{
id: "calendar-sync",
name: "calendar-sync",
description:
"Sync events across Google Calendar, Outlook, and Apple Calendar",
category: "Calendar",
installCommand: "npm install @tpmjs/calendar-sync",
usage: 43,
stars: 4891,
version: "2.0.5",
tags: ["calendar", "events", "sync"],
homepage: "https://tpmjs.com/tools/calendar-sync",
repository: "https://github.com/tpmjs/calendar-sync",
},
{
id: "semantic-search",
name: "semantic-search",
description:
"Vector-based semantic search with support for embeddings and similarity",
category: "Search",
installCommand: "npm install @tpmjs/semantic-search",
usage: 79,
stars: 11234,
version: "3.2.1",
tags: ["search", "embeddings", "vector", "ai"],
homepage: "https://tpmjs.com/tools/semantic-search",
repository: "https://github.com/tpmjs/semantic-search",
},
{
id: "code-execute",
name: "code-execute",
description:
"Safely execute code in sandboxed environments with timeout controls",
category: "Code Execution",
installCommand: "npm install @tpmjs/code-execute",
usage: 68,
stars: 9567,
version: "1.5.8",
tags: ["sandbox", "execution", "security"],
homepage: "https://tpmjs.com/tools/code-execute",
repository: "https://github.com/tpmjs/code-execute",
},
{
id: "slack-notify",
name: "slack-notify",
description:
"Send rich notifications to Slack channels with interactive components",
category: "Communication",
installCommand: "npm install @tpmjs/slack-notify",
usage: 54,
stars: 6234,
version: "2.3.2",
tags: ["slack", "notifications", "webhooks"],
homepage: "https://tpmjs.com/tools/slack-notify",
repository: "https://github.com/tpmjs/slack-notify",
},
{
id: "analytics-track",
name: "analytics-track",
description:
"Track user events and metrics across multiple analytics platforms",
category: "Analytics",
installCommand: "npm install @tpmjs/analytics-track",
usage: 61,
stars: 7123,
version: "1.8.4",
tags: ["analytics", "tracking", "metrics"],
homepage: "https://tpmjs.com/tools/analytics-track",
repository: "https://github.com/tpmjs/analytics-track",
},
{
id: "secret-vault",
name: "secret-vault",
description:
"Securely store and retrieve secrets with encryption and access control",
category: "Security",
installCommand: "npm install @tpmjs/secret-vault",
usage: 76,
stars: 10432,
version: "2.6.0",
tags: ["security", "secrets", "encryption"],
homepage: "https://tpmjs.com/tools/secret-vault",
repository: "https://github.com/tpmjs/secret-vault",
},
{
id: "workflow-orchestrate",
name: "workflow-orchestrate",
description:
"Orchestrate complex workflows with conditional logic and error handling",
category: "Workflows",
installCommand: "npm install @tpmjs/workflow-orchestrate",
usage: 47,
stars: 5789,
version: "3.1.2",
tags: ["workflows", "orchestration", "automation"],
homepage: "https://tpmjs.com/tools/workflow-orchestrate",
repository: "https://github.com/tpmjs/workflow-orchestrate",
},
];
/**
* Get tools by category
*/
export function getToolsByCategory(category: string): Tool[] {
return mockTools.filter((tool) => tool.category === category);
}
/**
* Get featured tools (top 6 by usage)
*/
export function getFeaturedTools(): Tool[] {
return [...mockTools].sort((a, b) => b.usage - a.usage).slice(0, 6);
}
/**
* Get tool by ID
*/
export function getToolById(id: string): Tool | undefined {
return mockTools.find((tool) => tool.id === id);
}
/**
* Search tools by name or description
*/
export function searchTools(query: string): Tool[] {
const lowerQuery = query.toLowerCase();
return mockTools.filter(
(tool) =>
tool.name.toLowerCase().includes(lowerQuery) ||
tool.description.toLowerCase().includes(lowerQuery) ||
tool.tags.some((tag) => tag.toLowerCase().includes(lowerQuery)),
);
}

View file

@ -1,42 +1,150 @@
import type { Config } from 'tailwindcss';
import type { Config } from "tailwindcss";
export default {
content: [],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
content: [],
theme: {
extend: {
colors: {
// Backgrounds & Surfaces
background: "hsl(var(--background))",
surface: "hsl(var(--surface))",
"surface-elevated": "hsl(var(--surface-elevated))",
"surface-overlay": "hsl(var(--surface-overlay))",
// Foreground (Text)
foreground: {
DEFAULT: "hsl(var(--foreground))",
secondary: "hsl(var(--foreground-secondary))",
tertiary: "hsl(var(--foreground-tertiary))",
muted: "hsl(var(--foreground-muted))",
},
// Borders
border: {
DEFAULT: "hsl(var(--border))",
strong: "hsl(var(--border-strong))",
subtle: "hsl(var(--border-subtle))",
},
// Interactive States
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
// Status Colors
success: {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))",
},
error: {
DEFAULT: "hsl(var(--error))",
foreground: "hsl(var(--error-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
info: {
DEFAULT: "hsl(var(--info))",
foreground: "hsl(var(--info-foreground))",
},
// Destructive (legacy support)
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
// Form Elements
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
// Card
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
// Grid/Blueprint
grid: "hsl(var(--grid-color))",
},
borderRadius: {
sm: "var(--radius-sm)",
md: "var(--radius-md)",
lg: "var(--radius-lg)",
xl: "var(--radius-xl)",
},
fontFamily: {
sans: "var(--font-sans)",
mono: "var(--font-mono)",
},
keyframes: {
"slide-up": {
from: { transform: "translateY(10px)", opacity: "0" },
to: { transform: "translateY(0)", opacity: "1" },
},
"slide-down": {
from: { transform: "translateY(-10px)", opacity: "0" },
to: { transform: "translateY(0)", opacity: "1" },
},
"slide-left": {
from: { transform: "translateX(10px)", opacity: "0" },
to: { transform: "translateX(0)", opacity: "1" },
},
"slide-right": {
from: { transform: "translateX(-10px)", opacity: "0" },
to: { transform: "translateX(0)", opacity: "1" },
},
"scale-in": {
from: { transform: "scale(0.95)", opacity: "0" },
to: { transform: "scale(1)", opacity: "1" },
},
"scale-out": {
from: { transform: "scale(1)", opacity: "1" },
to: { transform: "scale(0.95)", opacity: "0" },
},
shimmer: {
"0%": { backgroundPosition: "-1000px 0" },
"100%": { backgroundPosition: "1000px 0" },
},
pulse: {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.5" },
},
"grid-pulse": {
"0%, 100%": { opacity: "0.4" },
"50%": { opacity: "0.6" },
},
},
animation: {
"slide-up": "slide-up 300ms cubic-bezier(0.4, 0, 0.2, 1)",
"slide-down": "slide-down 300ms cubic-bezier(0.4, 0, 0.2, 1)",
"slide-left": "slide-left 300ms cubic-bezier(0.4, 0, 0.2, 1)",
"slide-right": "slide-right 300ms cubic-bezier(0.4, 0, 0.2, 1)",
"scale-in": "scale-in 200ms cubic-bezier(0.4, 0, 0.2, 1)",
"scale-out": "scale-out 200ms cubic-bezier(0.4, 0, 0.2, 1)",
shimmer: "shimmer 2s infinite linear",
pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
"grid-pulse": "grid-pulse 8s ease-in-out infinite",
},
},
},
plugins: [],
} satisfies Config;

View file

@ -0,0 +1,54 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Badge } from "@tpmjs/ui/Badge/Badge";
import { createElement } from "react";
const meta = {
title: "Components/Badge",
component: Badge,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
} satisfies Meta<typeof Badge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => createElement(Badge, {}, "Default"),
};
export const AllVariants: Story = {
render: () =>
createElement(
"div",
{
className: "flex flex-wrap items-center gap-3",
},
createElement(Badge, { variant: "default" }, "Default"),
createElement(Badge, { variant: "secondary" }, "Secondary"),
createElement(Badge, { variant: "outline" }, "Outline"),
createElement(Badge, { variant: "success" }, "Success"),
createElement(Badge, { variant: "error" }, "Error"),
createElement(Badge, { variant: "warning" }, "Warning"),
createElement(Badge, { variant: "info" }, "Info"),
),
};
export const AllSizes: Story = {
render: () =>
createElement(
"div",
{
className: "flex items-center gap-3",
},
createElement(Badge, { size: "sm" }, "Small"),
createElement(Badge, { size: "md" }, "Medium"),
createElement(Badge, { size: "lg" }, "Large"),
),
};
export const WithLongText: Story = {
render: () =>
createElement(Badge, {}, "This is a very long badge with lots of text"),
};

View file

@ -0,0 +1,79 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CodeBlock } from "@tpmjs/ui/CodeBlock/CodeBlock";
import { createElement } from "react";
const meta = {
title: "Components/CodeBlock",
component: CodeBlock,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
} satisfies Meta<typeof CodeBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () =>
createElement(CodeBlock, {
code: `console.log('Hello, World!');`,
}),
};
export const BashCommand: Story = {
render: () =>
createElement(CodeBlock, {
code: "npm install @tpmjs/ui",
language: "bash",
}),
};
export const TypeScriptCode: Story = {
render: () =>
createElement(CodeBlock, {
code: `import { Button } from '@tpmjs/ui/Button/Button';
import { createElement } from 'react';
export function MyComponent() {
return createElement(Button, {
variant: 'outline',
onClick: () => console.log('clicked'),
}, 'Click me');
}`,
language: "typescript",
}),
};
export const JSONData: Story = {
render: () =>
createElement(CodeBlock, {
code: `{
"name": "@tpmjs/ui",
"version": "0.0.1",
"description": "UI components for TPMJS"
}`,
language: "json",
}),
};
export const LongCode: Story = {
render: () =>
createElement(
"div",
{ className: "w-96" },
createElement(CodeBlock, {
code: `export const buttonVariants = createVariants({
base: 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2',
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-error text-error-foreground hover:bg-error/90',
outline: 'border border-border bg-background hover:bg-accent hover:text-accent-foreground',
},
},
});`,
language: "typescript",
}),
),
};

View file

@ -0,0 +1,102 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Icon } from "@tpmjs/ui/Icon/Icon";
import { createElement } from "react";
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: () =>
createElement(
"div",
{
className: "grid grid-cols-4 gap-8 p-8",
},
...(
[
"copy",
"externalLink",
"github",
"check",
"x",
"chevronDown",
"sun",
"moon",
] as const
).map((iconName) =>
createElement(
"div",
{
key: iconName,
className: "flex flex-col items-center gap-2",
},
createElement(Icon, { icon: iconName, size: "lg" }),
createElement(
"span",
{ className: "text-sm text-foreground-secondary" },
iconName,
),
),
),
),
};
export const AllSizes: Story = {
render: () =>
createElement(
"div",
{
className: "flex items-center gap-6",
},
...(["sm", "md", "lg"] as const).map((size) =>
createElement(
"div",
{
key: size,
className: "flex flex-col items-center gap-2",
},
createElement(Icon, { icon: "github", size }),
createElement(
"span",
{ className: "text-sm text-foreground-secondary" },
size,
),
),
),
),
};
export const WithColors: Story = {
render: () =>
createElement(
"div",
{
className: "flex items-center gap-6",
},
createElement(Icon, {
icon: "check",
size: "lg",
className: "text-success",
}),
createElement(Icon, { icon: "x", size: "lg", className: "text-error" }),
createElement(Icon, {
icon: "github",
size: "lg",
className: "text-info",
}),
createElement(Icon, {
icon: "sun",
size: "lg",
className: "text-warning",
}),
),
};

View file

@ -0,0 +1,94 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Input } from "@tpmjs/ui/Input/Input";
import { Label } from "@tpmjs/ui/Label/Label";
import { createElement } from "react";
const meta = {
title: "Components/Input",
component: Input,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
} satisfies Meta<typeof Input>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => createElement(Input, { placeholder: "Enter text..." }),
};
export const WithLabel: Story = {
render: () =>
createElement(
"div",
{ className: "flex flex-col gap-2 w-80" },
createElement(Label, { htmlFor: "email" }, "Email address"),
createElement(Input, {
id: "email",
type: "email",
placeholder: "name@example.com",
}),
),
};
export const AllStates: Story = {
render: () =>
createElement(
"div",
{ className: "flex flex-col gap-4 w-80" },
createElement(
"div",
{ className: "flex flex-col gap-2" },
createElement(Label, {}, "Normal"),
createElement(Input, { placeholder: "Normal state" }),
),
createElement(
"div",
{ className: "flex flex-col gap-2" },
createElement(Label, {}, "Success"),
createElement(Input, {
placeholder: "Success state",
state: "success",
}),
),
createElement(
"div",
{ className: "flex flex-col gap-2" },
createElement(Label, {}, "Error"),
createElement(Input, { placeholder: "Error state", state: "error" }),
),
createElement(
"div",
{ className: "flex flex-col gap-2" },
createElement(Label, {}, "Disabled"),
createElement(Input, { placeholder: "Disabled state", disabled: true }),
),
),
};
export const AllSizes: Story = {
render: () =>
createElement(
"div",
{ className: "flex flex-col gap-4 w-80" },
createElement(Input, { size: "sm", placeholder: "Small size" }),
createElement(Input, { size: "md", placeholder: "Medium size" }),
createElement(Input, { size: "lg", placeholder: "Large size" }),
),
};
export const PasswordInput: Story = {
render: () =>
createElement(
"div",
{ className: "flex flex-col gap-2 w-80" },
createElement(Label, { htmlFor: "password" }, "Password"),
createElement(Input, {
id: "password",
type: "password",
placeholder: "Enter password",
}),
),
};

View file

@ -0,0 +1,74 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ProgressBar } from "@tpmjs/ui/ProgressBar/ProgressBar";
import { createElement } from "react";
const meta = {
title: "Components/ProgressBar",
component: ProgressBar,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
} satisfies Meta<typeof ProgressBar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () =>
createElement(
"div",
{ className: "w-80" },
createElement(ProgressBar, { value: 50 }),
),
};
export const AllVariants: Story = {
render: () =>
createElement(
"div",
{ className: "flex flex-col gap-4 w-80" },
createElement(ProgressBar, { variant: "primary", value: 75 }),
createElement(ProgressBar, { variant: "success", value: 85 }),
createElement(ProgressBar, { variant: "warning", value: 60 }),
createElement(ProgressBar, { variant: "danger", value: 30 }),
),
};
export const AllSizes: Story = {
render: () =>
createElement(
"div",
{ className: "flex flex-col gap-4 w-80" },
createElement(ProgressBar, { size: "sm", value: 65 }),
createElement(ProgressBar, { size: "md", value: 65 }),
createElement(ProgressBar, { size: "lg", value: 65 }),
),
};
export const WithLabel: Story = {
render: () =>
createElement(
"div",
{ className: "w-80" },
createElement(ProgressBar, { value: 75, showLabel: true }),
),
};
export const ZeroPercent: Story = {
render: () =>
createElement(
"div",
{ className: "w-80" },
createElement(ProgressBar, { value: 0, showLabel: true }),
),
};
export const HundredPercent: Story = {
render: () =>
createElement(
"div",
{ className: "w-80" },
createElement(ProgressBar, { value: 100, showLabel: true }),
),
};

View file

@ -0,0 +1,91 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Tabs } from "@tpmjs/ui/Tabs/Tabs";
import type { Tab } from "@tpmjs/ui/Tabs/types";
import { createElement } from "react";
const meta = {
title: "Components/Tabs",
component: Tabs,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
} satisfies Meta<typeof Tabs>;
export default meta;
type Story = StoryObj<typeof meta>;
const mockTabs: Tab[] = [
{ id: "overview", label: "Overview" },
{ id: "usage", label: "Usage" },
{ id: "api", label: "API" },
];
export const Default: Story = {
render: () =>
createElement(Tabs, {
tabs: mockTabs,
activeTab: "overview",
onTabChange: (id: string) => console.log("Tab changed:", id),
}),
};
export const AllSizes: Story = {
render: () =>
createElement(
"div",
{ className: "flex flex-col gap-8 w-full" },
createElement(Tabs, {
tabs: mockTabs,
activeTab: "overview",
size: "sm",
onTabChange: () => {},
}),
createElement(Tabs, {
tabs: mockTabs,
activeTab: "overview",
size: "md",
onTabChange: () => {},
}),
createElement(Tabs, {
tabs: mockTabs,
activeTab: "overview",
size: "lg",
onTabChange: () => {},
}),
),
};
export const WithCounts: Story = {
render: () =>
createElement(Tabs, {
tabs: [
{ id: "all", label: "All Tools", count: 1234 },
{ id: "featured", label: "Featured", count: 42 },
{ id: "recent", label: "Recent", count: 18 },
],
activeTab: "all",
onTabChange: () => {},
}),
};
export const ManyTabs: Story = {
render: () =>
createElement(
"div",
{ className: "w-96" },
createElement(Tabs, {
tabs: [
{ id: "tab1", label: "Overview" },
{ id: "tab2", label: "Documentation" },
{ id: "tab3", label: "Examples" },
{ id: "tab4", label: "API Reference" },
{ id: "tab5", label: "Configuration" },
{ id: "tab6", label: "Changelog" },
{ id: "tab7", label: "Contributing" },
],
activeTab: "tab1",
onTabChange: () => {},
}),
),
};

View file

@ -3,24 +3,285 @@
@tailwind utilities;
@layer base {
/* Light mode (default) */
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
/* Backgrounds & Surfaces */
--background: 0 0% 100%; /* Pure white */
--surface: 210 20% 98%; /* Off-white */
--surface-elevated: 0 0% 100%; /* White (elevated) */
--surface-overlay: 0 0% 98%; /* Light gray */
/* Foreground (Text) */
--foreground: 222 47% 11%; /* Almost black */
--foreground-secondary: 215 16% 47%; /* Medium gray */
--foreground-tertiary: 215 16% 65%; /* Light gray */
--foreground-muted: 215 16% 75%; /* Very light gray */
/* Borders */
--border: 214 32% 91%; /* Light gray */
--border-strong: 214 32% 80%; /* Medium gray */
--border-subtle: 214 20% 95%; /* Very light gray */
/* Interactive States */
--primary: 222 47% 11%; /* Dark for light mode */
--primary-foreground: 210 40% 98%; /* Light text */
--secondary: 210 40% 96%; /* Light secondary */
--secondary-foreground: 222 47% 11%; /* Dark text */
--accent: 210 40% 96%; /* Accent bg */
--accent-foreground: 222 47% 11%; /* Accent text */
--muted: 210 40% 96%; /* Muted bg */
--muted-foreground: 215 16% 47%; /* Muted text */
/* Status Colors */
--success: 142 71% 45%;
--success-foreground: 142 76% 15%;
--error: 0 72% 51%;
--error-foreground: 0 86% 17%;
--warning: 38 92% 50%;
--warning-foreground: 48 96% 19%;
--info: 217 91% 60%;
--info-foreground: 214 95% 23%;
/* Destructive (legacy) */
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
/* Form Elements */
--input: 214 32% 91%;
--ring: 222 84% 5%;
--ring-offset: 0 0% 100%;
/* Grid/Blueprint Pattern */
--grid-color: 214 32% 95%;
--grid-size: 24px; /* Grid cell size */
/* Card */
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
/* Border Radii */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius: var(--radius-md);
/* Typography */
--font-sans: 'Geist', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--font-mono: 'Geist Mono', 'SF Mono', Consolas, monospace;
}
/* Dark mode (opt-in) - Vercel/Cursor/Perplexity aesthetic */
.dark {
/* Backgrounds & Surfaces */
--background: 210 10% 5%; /* #0d0d0f - Almost black */
--surface: 210 10% 8%; /* #14141a - Slightly lighter */
--surface-elevated: 210 10% 12%; /* #1c1c24 - Cards, modals */
--surface-overlay: 210 10% 16%; /* #25252f - Overlays, popovers */
/* Foreground (Text) */
--foreground: 210 10% 90%; /* #e3e3e8 - Primary text */
--foreground-secondary: 210 8% 60%; /* #949499 - Secondary text */
--foreground-tertiary: 210 6% 40%; /* #64646a - Tertiary text */
--foreground-muted: 210 6% 30%; /* #4a4a50 - Muted text */
/* Borders (1px, subtle) */
--border: 210 10% 20%; /* #30303a - Default border */
--border-strong: 210 12% 30%; /* #494954 - Emphasized border */
--border-subtle: 210 8% 15%; /* #22222c - Subtle border */
/* Interactive States */
--primary: 210 10% 98%; /* White-ish for dark mode */
--primary-foreground: 210 10% 5%; /* Dark text on light bg */
--secondary: 210 10% 15%; /* Dark secondary */
--secondary-foreground: 210 10% 90%; /* Light text on dark bg */
--accent: 210 10% 18%; /* Accent background */
--accent-foreground: 210 10% 95%; /* Accent text */
--muted: 210 10% 12%; /* Muted background */
--muted-foreground: 210 8% 60%; /* Muted text */
/* Status Colors (desaturated for dark mode) */
--success: 142 71% 45%; /* #10b981 - Green */
--success-foreground: 142 76% 95%; /* Light green text */
--error: 0 72% 51%; /* #ef4444 - Red */
--error-foreground: 0 86% 97%; /* Light red text */
--warning: 38 92% 50%; /* #f59e0b - Amber */
--warning-foreground: 48 96% 89%; /* Light amber text */
--info: 217 91% 60%; /* #3b82f6 - Blue */
--info-foreground: 214 95% 93%; /* Light blue text */
/* Destructive (legacy support) */
--destructive: 0 72% 51%;
--destructive-foreground: 0 86% 97%;
/* Form Elements */
--input: 210 10% 20%; /* Input border */
--ring: 210 10% 90%; /* Focus ring */
--ring-offset: 210 10% 5%; /* Focus ring offset */
/* Grid/Blueprint Pattern */
--grid-color: 210 10% 15%; /* Grid line color */
/* Card */
--card: 210 10% 8%;
--card-foreground: 210 10% 90%;
}
/* Base element styles */
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
font-feature-settings: 'rlig' 1, 'calt' 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Grid/Blueprint background patterns */
.grid-background {
background-image:
linear-gradient(hsl(var(--grid-color)) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--grid-color)) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
background-position: -1px -1px;
}
.dotted-grid-background {
background-image:
radial-gradient(circle, hsl(var(--grid-color)) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
}
.blueprint-background {
background-color: hsl(var(--background));
background-image:
/* Major grid lines (4x size) */
linear-gradient(hsl(var(--grid-color)) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--grid-color)) 1px, transparent 1px),
/* Minor grid lines (1x size) */
linear-gradient(hsl(var(--grid-color) / 0.3) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--grid-color) / 0.3) 1px, transparent 1px);
background-size:
calc(var(--grid-size) * 4) calc(var(--grid-size) * 4),
calc(var(--grid-size) * 4) calc(var(--grid-size) * 4),
var(--grid-size) var(--grid-size),
var(--grid-size) var(--grid-size);
}
/* Animated grid (subtle pulse) */
.grid-background-animated {
@apply grid-background;
animation: grid-pulse 8s ease-in-out infinite;
}
@keyframes grid-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.6; }
}
}
@layer utilities {
/* Border utilities */
.border-1 {
border-width: 1px;
}
.border-dotted-1 {
border-width: 1px;
border-style: dotted;
}
.border-dashed-1 {
border-width: 1px;
border-style: dashed;
}
/* Directional dotted borders */
.border-t-dotted {
border-top-width: 1px;
border-top-style: dotted;
}
.border-b-dotted {
border-bottom-width: 1px;
border-bottom-style: dotted;
}
.border-l-dotted {
border-left-width: 1px;
border-left-style: dotted;
}
.border-r-dotted {
border-right-width: 1px;
border-right-style: dotted;
}
/* Transition presets */
.transition-base {
transition-property: background-color, border-color, color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
.transition-transform {
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
.transition-all-smooth {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
/* Hover effects */
.hover-lift {
@apply transition-transform hover:-translate-y-0.5 hover:shadow-md;
}
/* Focus ring (modern, accessible) */
.focus-ring {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
/* Custom animations */
@keyframes slide-up {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.animate-slide-up {
animation: slide-up 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.animate-shimmer {
animation: shimmer 2s infinite linear;
background: linear-gradient(
90deg,
hsl(var(--muted)) 0%,
hsl(var(--accent)) 50%,
hsl(var(--muted)) 100%
);
background-size: 1000px 100%;
}
}

View file

@ -1,52 +1,87 @@
{
"name": "@tpmjs/ui",
"version": "0.1.2",
"type": "module",
"exports": {
"./Button/Button": {
"types": "./dist/Button/Button.d.ts",
"default": "./dist/Button/Button.js"
},
"./Card/Card": {
"types": "./dist/Card/Card.d.ts",
"default": "./dist/Card/Card.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",
"test:ui": "vitest --ui",
"type-check": "tsc --noEmit",
"lint": "eslint .",
"clean": "rm -rf dist .turbo"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"dependencies": {
"@tpmjs/utils": "workspace:*"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@tpmjs/eslint-config": "workspace:*",
"@tpmjs/test": "workspace:*",
"@tpmjs/tsconfig": "workspace:*",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"happy-dom": "^15.11.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"publishConfig": {
"access": "public"
}
"name": "@tpmjs/ui",
"version": "0.1.2",
"type": "module",
"exports": {
"./Button/Button": {
"types": "./dist/Button/Button.d.ts",
"default": "./dist/Button/Button.js"
},
"./Card/Card": {
"types": "./dist/Card/Card.d.ts",
"default": "./dist/Card/Card.js"
},
"./Input/Input": {
"types": "./dist/Input/Input.d.ts",
"default": "./dist/Input/Input.js"
},
"./Label/Label": {
"types": "./dist/Label/Label.d.ts",
"default": "./dist/Label/Label.js"
},
"./Badge/Badge": {
"types": "./dist/Badge/Badge.d.ts",
"default": "./dist/Badge/Badge.js"
},
"./Container/Container": {
"types": "./dist/Container/Container.d.ts",
"default": "./dist/Container/Container.js"
},
"./Icon/Icon": {
"types": "./dist/Icon/Icon.d.ts",
"default": "./dist/Icon/Icon.js"
},
"./ProgressBar/ProgressBar": {
"types": "./dist/ProgressBar/ProgressBar.d.ts",
"default": "./dist/ProgressBar/ProgressBar.js"
},
"./CodeBlock/CodeBlock": {
"types": "./dist/CodeBlock/CodeBlock.d.ts",
"default": "./dist/CodeBlock/CodeBlock.js"
},
"./Tabs/Tabs": {
"types": "./dist/Tabs/Tabs.d.ts",
"default": "./dist/Tabs/Tabs.js"
},
"./Header/Header": {
"types": "./dist/Header/Header.d.ts",
"default": "./dist/Header/Header.js"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",
"test:ui": "vitest --ui",
"type-check": "tsc --noEmit",
"lint": "eslint .",
"clean": "rm -rf dist .turbo"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"dependencies": {
"@tpmjs/utils": "workspace:*"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@tpmjs/eslint-config": "workspace:*",
"@tpmjs/test": "workspace:*",
"@tpmjs/tsconfig": "workspace:*",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"glob": "^13.0.0",
"happy-dom": "^15.11.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"publishConfig": {
"access": "public"
}
}

View file

@ -0,0 +1,542 @@
import { render, screen } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it } from "vitest";
import { Badge } from "./Badge";
describe("Badge", () => {
describe("Rendering", () => {
it("renders a badge element", () => {
render(createElement(Badge, { "data-testid": "badge" }, "Badge"));
const badge = screen.getByTestId("badge");
expect(badge).toBeInTheDocument();
expect(badge.tagName).toBe("DIV");
});
it("renders children text", () => {
render(createElement(Badge, null, "Active"));
expect(screen.getByText("Active")).toBeInTheDocument();
});
});
describe("Variants", () => {
it("applies default variant classes", () => {
render(
createElement(
Badge,
{
variant: "default",
"data-testid": "badge",
},
"Default",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-primary");
expect(badge.className).toContain("text-primary-foreground");
expect(badge.className).toContain("border-primary");
});
it("applies secondary variant classes", () => {
render(
createElement(
Badge,
{
variant: "secondary",
"data-testid": "badge",
},
"Secondary",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-secondary");
expect(badge.className).toContain("text-secondary-foreground");
});
it("applies outline variant classes", () => {
render(
createElement(
Badge,
{
variant: "outline",
"data-testid": "badge",
},
"Outline",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-transparent");
expect(badge.className).toContain("text-foreground");
expect(badge.className).toContain("border-border");
});
it("applies success variant classes", () => {
render(
createElement(
Badge,
{
variant: "success",
"data-testid": "badge",
},
"Success",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-success");
expect(badge.className).toContain("text-success-foreground");
});
it("applies error variant classes", () => {
render(
createElement(
Badge,
{
variant: "error",
"data-testid": "badge",
},
"Error",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-error");
expect(badge.className).toContain("text-error-foreground");
});
it("applies warning variant classes", () => {
render(
createElement(
Badge,
{
variant: "warning",
"data-testid": "badge",
},
"Warning",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-warning");
expect(badge.className).toContain("text-warning-foreground");
});
it("applies info variant classes", () => {
render(
createElement(
Badge,
{
variant: "info",
"data-testid": "badge",
},
"Info",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-info");
expect(badge.className).toContain("text-info-foreground");
});
});
describe("Sizes", () => {
it("applies small size classes", () => {
render(
createElement(
Badge,
{
size: "sm",
"data-testid": "badge",
},
"Small",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("px-2");
expect(badge.className).toContain("py-0.5");
expect(badge.className).toContain("text-xs");
});
it("applies medium size classes (default)", () => {
render(
createElement(
Badge,
{
size: "md",
"data-testid": "badge",
},
"Medium",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("px-2.5");
expect(badge.className).toContain("py-1");
expect(badge.className).toContain("text-sm");
});
it("applies large size classes", () => {
render(
createElement(
Badge,
{
size: "lg",
"data-testid": "badge",
},
"Large",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("px-3");
expect(badge.className).toContain("py-1.5");
expect(badge.className).toContain("text-base");
});
it("uses medium size by default", () => {
render(
createElement(
Badge,
{
"data-testid": "badge",
},
"Default",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("text-sm");
});
});
describe("HTML Attributes", () => {
it("passes through id attribute", () => {
render(
createElement(
Badge,
{
id: "badge-id",
"data-testid": "badge",
},
"Badge",
),
);
const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("id", "badge-id");
});
it("passes through title attribute", () => {
render(
createElement(
Badge,
{
title: "Badge tooltip",
"data-testid": "badge",
},
"Badge",
),
);
const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("title", "Badge tooltip");
});
it("passes through onClick handler", () => {
let clicked = false;
render(
createElement(
Badge,
{
onClick: () => {
clicked = true;
},
"data-testid": "badge",
},
"Clickable",
),
);
const badge = screen.getByTestId("badge");
badge.click();
expect(clicked).toBe(true);
});
it("passes through role attribute", () => {
render(
createElement(
Badge,
{
role: "status",
"data-testid": "badge",
},
"Status",
),
);
const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("role", "status");
});
});
describe("ARIA Attributes", () => {
it("passes through aria-label", () => {
render(
createElement(
Badge,
{
"aria-label": "Status badge",
"data-testid": "badge",
},
"Active",
),
);
const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("aria-label", "Status badge");
});
it("passes through aria-describedby", () => {
render(
createElement(
Badge,
{
"aria-describedby": "description-id",
"data-testid": "badge",
},
"Badge",
),
);
const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("aria-describedby", "description-id");
});
it("passes through aria-live for dynamic status", () => {
render(
createElement(
Badge,
{
"aria-live": "polite",
"data-testid": "badge",
},
"Updating",
),
);
const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("aria-live", "polite");
});
});
describe("Custom className", () => {
it("merges custom className with variant classes", () => {
render(
createElement(
Badge,
{
className: "custom-badge",
"data-testid": "badge",
},
"Custom",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("custom-badge");
expect(badge.className).toContain("rounded-full");
expect(badge.className).toContain("bg-primary");
});
});
describe("Ref Forwarding", () => {
it("forwards ref to badge element", () => {
let ref: HTMLDivElement | null = null;
render(
createElement(
Badge,
{
ref: (el: HTMLDivElement | null) => {
ref = el;
},
},
"Badge",
),
);
expect(ref).toBeInstanceOf(HTMLDivElement);
expect(ref?.tagName).toBe("DIV");
});
});
describe("Base Classes", () => {
it("always includes base classes", () => {
render(
createElement(
Badge,
{
"data-testid": "badge",
},
"Badge",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("inline-flex");
expect(badge.className).toContain("items-center");
expect(badge.className).toContain("font-semibold");
expect(badge.className).toContain("whitespace-nowrap");
expect(badge.className).toContain("rounded-full");
});
it("includes transition classes", () => {
render(
createElement(
Badge,
{
"data-testid": "badge",
},
"Badge",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("transition-base");
});
});
describe("Compound Scenarios", () => {
it("works correctly with success variant and small size", () => {
render(
createElement(
Badge,
{
variant: "success",
size: "sm",
"data-testid": "badge",
},
"Success",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-success");
expect(badge.className).toContain("text-xs");
});
it("works correctly with error variant and large size", () => {
render(
createElement(
Badge,
{
variant: "error",
size: "lg",
"data-testid": "badge",
},
"Error",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-error");
expect(badge.className).toContain("text-base");
});
it("works correctly with outline variant and custom className", () => {
render(
createElement(
Badge,
{
variant: "outline",
className: "hover:bg-accent",
"data-testid": "badge",
},
"Outlined",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-transparent");
expect(badge.className).toContain("hover:bg-accent");
});
it("works correctly with warning variant and onClick", () => {
let clicked = false;
render(
createElement(
Badge,
{
variant: "warning",
onClick: () => {
clicked = true;
},
"data-testid": "badge",
},
"Warning",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-warning");
badge.click();
expect(clicked).toBe(true);
});
});
describe("Content Types", () => {
it("renders with text content", () => {
render(createElement(Badge, null, "Text"));
expect(screen.getByText("Text")).toBeInTheDocument();
});
it("renders with numeric content", () => {
render(createElement(Badge, null, "42"));
expect(screen.getByText("42")).toBeInTheDocument();
});
it("renders with multiple children", () => {
render(
createElement(
Badge,
null,
"Count: ",
createElement("strong", null, "5"),
),
);
expect(screen.getByText("Count:")).toBeInTheDocument();
expect(screen.getByText("5")).toBeInTheDocument();
});
});
describe("Semantic Usage", () => {
it("can be used as status indicator with role", () => {
render(
createElement(
Badge,
{
variant: "success",
role: "status",
"aria-label": "Online status",
"data-testid": "badge",
},
"Online",
),
);
const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("role", "status");
expect(badge).toHaveAttribute("aria-label", "Online status");
expect(badge.className).toContain("bg-success");
});
it("can be used as notification count", () => {
render(
createElement(
Badge,
{
variant: "error",
size: "sm",
"aria-label": "3 unread messages",
"data-testid": "badge",
},
"3",
),
);
const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("aria-label", "3 unread messages");
expect(screen.getByText("3")).toBeInTheDocument();
});
it("can be used as tag/label", () => {
render(
createElement(
Badge,
{
variant: "outline",
"data-testid": "badge",
},
"TypeScript",
),
);
const badge = screen.getByTestId("badge");
expect(badge.className).toContain("bg-transparent");
expect(screen.getByText("TypeScript")).toBeInTheDocument();
});
});
});

View file

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

View file

@ -0,0 +1,41 @@
import { borderRadius, spacing } from "../tokens";
/**
* Badge component design tokens
* Maps global tokens to badge-specific semantics
*/
export const badgeTokens = {
/** Horizontal padding for each size */
padding: {
x: {
sm: spacing[2], // 0.5rem (8px)
md: spacing[2.5], // 0.625rem (10px)
lg: spacing[3], // 0.75rem (12px)
},
y: {
sm: spacing[0.5], // 0.125rem (2px)
md: spacing[1], // 0.25rem (4px)
lg: spacing[1.5], // 0.375rem (6px)
},
},
/** Font sizes for each size */
fontSize: {
sm: "0.75rem", // 12px
md: "0.875rem", // 14px
lg: "1rem", // 16px
},
/** Line heights for each size */
lineHeight: {
sm: "1rem", // 16px
md: "1.25rem", // 20px
lg: "1.5rem", // 24px
},
/** Border radius */
borderRadius: borderRadius.full,
/** Border width */
borderWidth: "1px",
} as const;

View file

@ -0,0 +1,30 @@
import type { HTMLAttributes } from "react";
/**
* Badge component props
*/
export interface BadgeProps extends HTMLAttributes<HTMLDivElement> {
/**
* Visual variant of the badge
* @default 'default'
*/
variant?:
| "default"
| "secondary"
| "outline"
| "success"
| "error"
| "warning"
| "info";
/**
* Size of the badge
* @default 'md'
*/
size?: "sm" | "md" | "lg";
}
/**
* Badge ref type
*/
export type BadgeRef = HTMLDivElement;

View file

@ -0,0 +1,66 @@
import { createVariants } from "../system/variants";
/**
* Badge variant definitions
* Uses custom variant system for type-safe class composition
*/
export const badgeVariants = createVariants({
base: [
// Layout
"inline-flex items-center",
// Typography
"font-semibold",
"whitespace-nowrap",
// Borders & Radius
"rounded-full",
// Transitions
"transition-base",
].join(" "),
variants: {
variant: {
default: [
"bg-primary text-primary-foreground",
"border border-primary",
].join(" "),
secondary: [
"bg-secondary text-secondary-foreground",
"border border-secondary",
].join(" "),
outline: ["bg-transparent text-foreground", "border border-border"].join(
" ",
),
success: [
"bg-success text-success-foreground",
"border border-success",
].join(" "),
error: ["bg-error text-error-foreground", "border border-error"].join(
" ",
),
warning: [
"bg-warning text-warning-foreground",
"border border-warning",
].join(" "),
info: ["bg-info text-info-foreground", "border border-info"].join(" "),
},
size: {
sm: "px-2 py-0.5 text-xs",
md: "px-2.5 py-1 text-sm",
lg: "px-3 py-1.5 text-base",
},
},
compoundVariants: [],
defaultVariants: {
variant: "default",
size: "md",
},
});

View file

@ -1,23 +1,383 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Button } from './Button';
import { createElement } from 'react';
import { render, screen } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it } from "vitest";
import { Button } from "./Button";
describe('Button', () => {
it('renders with default variant', () => {
render(createElement(Button, { children: 'Click me' }));
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});
describe("Button", () => {
describe("Rendering", () => {
it("renders with default variant and size", () => {
render(createElement(Button, {}, "Click me"));
const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent("Click me");
});
it('applies variant classes', () => {
render(createElement(Button, { variant: 'destructive', children: 'Delete' }));
const button = screen.getByRole('button');
expect(button.className).toContain('bg-destructive');
});
it("renders as a button element", () => {
render(createElement(Button, {}, "Test"));
expect(screen.getByRole("button").tagName).toBe("BUTTON");
});
});
it('applies size classes', () => {
render(createElement(Button, { size: 'lg', children: 'Large' }));
const button = screen.getByRole('button');
expect(button.className).toContain('h-11');
});
describe("Variants", () => {
it("applies default variant classes", () => {
render(
createElement(Button, {
variant: "default",
"data-testid": "button",
children: "Default",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("bg-primary");
expect(button.className).toContain("text-primary-foreground");
});
it("applies destructive variant classes", () => {
render(
createElement(Button, {
variant: "destructive",
"data-testid": "button",
children: "Delete",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("bg-error");
expect(button.className).toContain("text-error-foreground");
});
it("applies outline variant classes", () => {
render(
createElement(Button, {
variant: "outline",
"data-testid": "button",
children: "Outline",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("border");
expect(button.className).toContain("bg-transparent");
});
it("applies secondary variant classes", () => {
render(
createElement(Button, {
variant: "secondary",
"data-testid": "button",
children: "Secondary",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("bg-secondary");
expect(button.className).toContain("text-secondary-foreground");
});
it("applies ghost variant classes", () => {
render(
createElement(Button, {
variant: "ghost",
"data-testid": "button",
children: "Ghost",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("hover:bg-accent");
});
it("applies link variant classes", () => {
render(
createElement(Button, {
variant: "link",
"data-testid": "button",
children: "Link",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("text-primary");
expect(button.className).toContain("underline-offset-4");
});
});
describe("Sizes", () => {
it("applies small size classes", () => {
render(
createElement(Button, {
size: "sm",
"data-testid": "button",
children: "Small",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("h-9");
expect(button.className).toContain("px-3");
});
it("applies medium size classes (default)", () => {
render(
createElement(Button, {
size: "md",
"data-testid": "button",
children: "Medium",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("h-10");
expect(button.className).toContain("px-4");
});
it("applies large size classes", () => {
render(
createElement(Button, {
size: "lg",
"data-testid": "button",
children: "Large",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("h-11");
expect(button.className).toContain("px-8");
});
it("applies icon size classes", () => {
render(
createElement(Button, {
size: "icon",
"data-testid": "button",
"aria-label": "Icon button",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("h-10");
expect(button.className).toContain("w-10");
});
});
describe("Compound Variants", () => {
it("applies compound variant for outline + small", () => {
render(
createElement(Button, {
variant: "outline",
size: "sm",
"data-testid": "button",
children: "Small Outline",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("border");
expect(button.className).toContain("h-9");
});
it("applies compound variant for link variants (removes padding)", () => {
render(
createElement(Button, {
variant: "link",
size: "md",
"data-testid": "button",
children: "Link",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("px-0");
});
});
describe("Loading State", () => {
it("shows loading spinner when loading is true", () => {
render(createElement(Button, { loading: true, children: "Loading" }));
const button = screen.getByRole("button");
// Should have loading indicator
const spinner = button.querySelector('[aria-hidden="true"]');
expect(spinner).toBeInTheDocument();
expect(spinner?.className).toContain("animate-spin");
});
it("disables button when loading", () => {
render(createElement(Button, { loading: true, children: "Loading" }));
const button = screen.getByRole("button");
expect(button).toBeDisabled();
expect(button).toHaveAttribute("aria-busy", "true");
});
it("applies cursor-wait class when loading", () => {
render(
createElement(Button, {
loading: true,
"data-testid": "button",
children: "Loading",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("cursor-wait");
});
it("still shows children text when loading", () => {
render(createElement(Button, { loading: true, children: "Processing" }));
expect(screen.getByText("Processing")).toBeInTheDocument();
});
});
describe("Disabled State", () => {
it("disables button when disabled prop is true", () => {
render(createElement(Button, { disabled: true, children: "Disabled" }));
const button = screen.getByRole("button");
expect(button).toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
});
it("applies disabled opacity class", () => {
render(
createElement(Button, {
disabled: true,
"data-testid": "button",
children: "Disabled",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("disabled:opacity-50");
expect(button.className).toContain("disabled:pointer-events-none");
});
it("is disabled when both disabled and loading are true", () => {
render(
createElement(Button, {
disabled: true,
loading: true,
children: "Both",
}),
);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
});
});
describe("Custom className", () => {
it("merges custom className with variant classes", () => {
render(
createElement(Button, {
className: "custom-class",
"data-testid": "button",
children: "Custom",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("custom-class");
expect(button.className).toContain("bg-primary");
});
});
describe("Ref Forwarding", () => {
it("forwards ref to button element", () => {
let ref: HTMLButtonElement | null = null;
render(
createElement(Button, {
ref: (el: HTMLButtonElement | null) => {
ref = el;
},
children: "Ref Test",
}),
);
expect(ref).toBeInstanceOf(HTMLButtonElement);
expect(ref?.tagName).toBe("BUTTON");
});
});
describe("HTML Attributes", () => {
it("passes through HTML button attributes", () => {
render(
createElement(Button, {
type: "submit",
name: "test-button",
value: "test-value",
"data-testid": "button",
children: "Submit",
}),
);
const button = screen.getByTestId("button");
expect(button).toHaveAttribute("type", "submit");
expect(button).toHaveAttribute("name", "test-button");
expect(button).toHaveAttribute("value", "test-value");
});
it("supports onClick handler", () => {
let clicked = false;
render(
createElement(Button, {
onClick: () => {
clicked = true;
},
children: "Click",
}),
);
const button = screen.getByRole("button");
button.click();
expect(clicked).toBe(true);
});
});
describe("Accessibility", () => {
it("has proper ARIA attributes for loading state", () => {
render(createElement(Button, { loading: true, children: "Loading" }));
const button = screen.getByRole("button");
expect(button).toHaveAttribute("aria-busy", "true");
expect(button).toHaveAttribute("aria-disabled", "true");
});
it("has proper ARIA attributes for disabled state", () => {
render(createElement(Button, { disabled: true, children: "Disabled" }));
const button = screen.getByRole("button");
expect(button).toHaveAttribute("aria-disabled", "true");
});
it("hides loading spinner from screen readers", () => {
render(createElement(Button, { loading: true, children: "Loading" }));
const button = screen.getByRole("button");
const spinner = button.querySelector('[aria-hidden="true"]');
expect(spinner).toHaveAttribute("aria-hidden", "true");
});
it("supports aria-label for icon buttons", () => {
render(
createElement(Button, {
size: "icon",
"aria-label": "Close dialog",
children: "X",
}),
);
const button = screen.getByRole("button");
expect(button).toHaveAccessibleName("Close dialog");
});
});
describe("Base Classes", () => {
it("always includes base classes", () => {
render(
createElement(Button, { "data-testid": "button", children: "Base" }),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("inline-flex");
expect(button.className).toContain("items-center");
expect(button.className).toContain("justify-center");
expect(button.className).toContain("font-medium");
expect(button.className).toContain("rounded-md");
});
it("includes focus ring classes", () => {
render(
createElement(Button, { "data-testid": "button", children: "Focus" }),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("focus-ring");
});
it("includes transition classes", () => {
render(
createElement(Button, {
"data-testid": "button",
children: "Transition",
}),
);
const button = screen.getByTestId("button");
expect(button.className).toContain("transition-base");
});
});
});

View file

@ -1,40 +1,74 @@
import { createElement, forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '@tpmjs/utils/cn';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
}
const buttonVariants = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
};
const buttonSizes = {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
};
import { cn } from "@tpmjs/utils/cn";
import { createElement, forwardRef } from "react";
import type { ButtonProps } from "./types";
import { buttonVariants } from "./variants";
/**
* Button component
*
* A versatile button component with multiple variants, sizes, and states.
* Built with .ts-only React using createElement.
*
* @example
* ```typescript
* import { Button } from '@tpmjs/ui/Button/Button';
* import { createElement } from 'react';
*
* function MyComponent() {
* return createElement(Button, {
* variant: 'outline',
* size: 'lg',
* onClick: () => console.log('clicked'),
* children: 'Click me',
* });
* }
* ```
*/
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
return createElement('button', {
className: cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
buttonVariants[variant],
buttonSizes[size],
className
),
ref,
...props,
});
}
(
{
className,
variant = "default",
size = "md",
loading = false,
disabled = false,
children,
...props
},
ref,
) => {
return createElement(
"button",
{
type: "button",
className: cn(
buttonVariants({
variant,
size,
loading: loading ? "true" : "false",
}),
className,
),
ref,
disabled: disabled || loading,
"aria-disabled": disabled || loading ? "true" : undefined,
"aria-busy": loading ? "true" : undefined,
...props,
},
loading
? createElement(
"span",
{ className: "flex items-center gap-2" },
createElement("span", {
className:
"h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent",
"aria-hidden": "true",
}),
createElement("span", null, children),
)
: children,
);
},
);
Button.displayName = 'Button';
Button.displayName = "Button";

View file

@ -0,0 +1,33 @@
import { borderRadius, spacing } from "../tokens";
/**
* Button component design tokens
* Maps global tokens to button-specific semantics
*/
export const buttonTokens = {
/** Button heights for each size */
height: {
sm: spacing[9], // 2.25rem (36px)
md: spacing[10], // 2.5rem (40px)
lg: spacing[11], // 2.75rem (44px)
icon: spacing[10], // 2.5rem (40px) - square
},
/** Horizontal padding for each size */
padding: {
x: {
sm: spacing[3], // 0.75rem (12px)
md: spacing[4], // 1rem (16px)
lg: spacing[8], // 2rem (32px)
},
},
/** Gap between icon and text */
gap: spacing[2], // 0.5rem (8px)
/** Border radius */
borderRadius: borderRadius.md,
/** Transition duration */
transitionDuration: "200ms",
} as const;

View file

@ -0,0 +1,35 @@
import type { ButtonHTMLAttributes } from "react";
/**
* Button component props
*/
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/**
* Visual variant of the button
* @default 'default'
*/
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
/**
* Size of the button
* @default 'md'
*/
size?: "sm" | "md" | "lg" | "icon";
/**
* Loading state - disables button and shows loading indicator
* @default false
*/
loading?: boolean;
}
/**
* Button ref type
*/
export type ButtonRef = HTMLButtonElement;

View file

@ -0,0 +1,99 @@
import { createVariants } from "../system/variants";
/**
* Button variant definitions
* Uses custom variant system for type-safe class composition
*/
export const buttonVariants = createVariants({
base: [
// Layout
"inline-flex items-center justify-center gap-2",
// Typography
"font-medium text-sm",
"whitespace-nowrap",
// Borders & Radius
"rounded-md",
// Transitions
"transition-base",
// Focus
"focus-ring",
// Disabled state
"disabled:pointer-events-none disabled:opacity-50",
].join(" "),
variants: {
variant: {
default: [
"bg-primary text-primary-foreground",
"hover:bg-primary/90",
"active:bg-primary/80",
].join(" "),
destructive: [
"bg-error text-error-foreground",
"hover:bg-error/90",
"active:bg-error/80",
].join(" "),
outline: [
"border border-border bg-transparent",
"hover:bg-accent hover:text-accent-foreground hover:border-border-strong",
"active:bg-accent/80",
].join(" "),
secondary: [
"bg-secondary text-secondary-foreground",
"hover:bg-secondary/80",
"active:bg-secondary/70",
].join(" "),
ghost: [
"hover:bg-accent hover:text-accent-foreground",
"active:bg-accent/80",
].join(" "),
link: [
"text-primary underline-offset-4",
"hover:underline",
"active:text-primary/80",
].join(" "),
},
size: {
sm: "h-9 px-3 text-sm",
md: "h-10 px-4 text-base",
lg: "h-11 px-8 text-lg",
icon: "h-10 w-10 p-0",
},
loading: {
true: "cursor-wait",
false: "",
},
},
compoundVariants: [
{
conditions: { variant: "outline", size: "sm" },
className: "border-1",
},
{
conditions: { variant: "link", size: "sm" },
className: "px-0",
},
{
conditions: { variant: "link", size: "md" },
className: "px-0",
},
{
conditions: { variant: "link", size: "lg" },
className: "px-0",
},
],
defaultVariants: {
variant: "default",
size: "md",
loading: "false",
},
});

View file

@ -0,0 +1,876 @@
import { render, screen } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it } from "vitest";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./Card";
describe("Card", () => {
describe("Rendering", () => {
it("renders a card element", () => {
render(createElement(Card, { "data-testid": "card" }, "Card content"));
const card = screen.getByTestId("card");
expect(card).toBeInTheDocument();
expect(card.tagName).toBe("DIV");
});
it("renders with all sub-components", () => {
render(
createElement(
Card,
{ "data-testid": "card" },
createElement(
CardHeader,
{ "data-testid": "header" },
createElement(CardTitle, { "data-testid": "title" }, "Title"),
createElement(
CardDescription,
{ "data-testid": "description" },
"Description",
),
),
createElement(CardContent, { "data-testid": "content" }, "Content"),
createElement(CardFooter, { "data-testid": "footer" }, "Footer"),
),
);
expect(screen.getByTestId("card")).toBeInTheDocument();
expect(screen.getByTestId("header")).toBeInTheDocument();
expect(screen.getByTestId("title")).toBeInTheDocument();
expect(screen.getByTestId("description")).toBeInTheDocument();
expect(screen.getByTestId("content")).toBeInTheDocument();
expect(screen.getByTestId("footer")).toBeInTheDocument();
});
});
describe("Card Variants", () => {
it("applies default variant classes", () => {
render(
createElement(
Card,
{
variant: "default",
"data-testid": "card",
},
"Default",
),
);
const card = screen.getByTestId("card");
expect(card.className).toContain("border");
expect(card.className).toContain("bg-card");
expect(card.className).toContain("shadow-sm");
});
it("applies elevated variant classes", () => {
render(
createElement(
Card,
{
variant: "elevated",
"data-testid": "card",
},
"Elevated",
),
);
const card = screen.getByTestId("card");
expect(card.className).toContain("bg-surface-elevated");
expect(card.className).toContain("shadow-md");
});
it("applies outline variant classes", () => {
render(
createElement(
Card,
{
variant: "outline",
"data-testid": "card",
},
"Outline",
),
);
const card = screen.getByTestId("card");
expect(card.className).toContain("border-2");
expect(card.className).toContain("bg-transparent");
});
it("applies ghost variant classes", () => {
render(
createElement(
Card,
{
variant: "ghost",
"data-testid": "card",
},
"Ghost",
),
);
const card = screen.getByTestId("card");
expect(card.className).toContain("bg-transparent");
});
});
describe("Card Padding", () => {
it("applies no padding by default", () => {
render(
createElement(
Card,
{
"data-testid": "card",
},
"No padding",
),
);
const card = screen.getByTestId("card");
expect(card.className).toContain("p-0");
});
it("applies small padding", () => {
render(
createElement(
Card,
{
padding: "sm",
"data-testid": "card",
},
"Small padding",
),
);
const card = screen.getByTestId("card");
expect(card.className).toContain("p-4");
});
it("applies medium padding", () => {
render(
createElement(
Card,
{
padding: "md",
"data-testid": "card",
},
"Medium padding",
),
);
const card = screen.getByTestId("card");
expect(card.className).toContain("p-6");
});
it("applies large padding", () => {
render(
createElement(
Card,
{
padding: "lg",
"data-testid": "card",
},
"Large padding",
),
);
const card = screen.getByTestId("card");
expect(card.className).toContain("p-8");
});
});
describe("CardHeader", () => {
it("renders with default medium padding", () => {
render(
createElement(
Card,
null,
createElement(CardHeader, { "data-testid": "header" }, "Header"),
),
);
const header = screen.getByTestId("header");
expect(header.className).toContain("p-6");
});
it("applies small padding", () => {
render(
createElement(
Card,
null,
createElement(
CardHeader,
{ padding: "sm", "data-testid": "header" },
"Header",
),
),
);
const header = screen.getByTestId("header");
expect(header.className).toContain("p-4");
});
it("applies large padding", () => {
render(
createElement(
Card,
null,
createElement(
CardHeader,
{ padding: "lg", "data-testid": "header" },
"Header",
),
),
);
const header = screen.getByTestId("header");
expect(header.className).toContain("p-8");
});
it("applies no padding", () => {
render(
createElement(
Card,
null,
createElement(
CardHeader,
{ padding: "none", "data-testid": "header" },
"Header",
),
),
);
const header = screen.getByTestId("header");
expect(header.className).toContain("p-0");
});
});
describe("CardTitle", () => {
it("renders as h3 by default", () => {
render(
createElement(
Card,
null,
createElement(
CardHeader,
null,
createElement(CardTitle, { "data-testid": "title" }, "Title"),
),
),
);
const title = screen.getByTestId("title");
expect(title.tagName).toBe("H3");
});
it("renders as h1 when specified", () => {
render(
createElement(
Card,
null,
createElement(
CardHeader,
null,
createElement(
CardTitle,
{ as: "h1", "data-testid": "title" },
"Title",
),
),
),
);
const title = screen.getByTestId("title");
expect(title.tagName).toBe("H1");
});
it("renders as h2 when specified", () => {
render(
createElement(
Card,
null,
createElement(
CardHeader,
null,
createElement(
CardTitle,
{ as: "h2", "data-testid": "title" },
"Title",
),
),
),
);
const title = screen.getByTestId("title");
expect(title.tagName).toBe("H2");
});
it("applies title classes", () => {
render(
createElement(
Card,
null,
createElement(
CardHeader,
null,
createElement(CardTitle, { "data-testid": "title" }, "Title"),
),
),
);
const title = screen.getByTestId("title");
expect(title.className).toContain("text-2xl");
expect(title.className).toContain("font-semibold");
expect(title.className).toContain("leading-none");
});
});
describe("CardDescription", () => {
it("renders as a paragraph", () => {
render(
createElement(
Card,
null,
createElement(
CardHeader,
null,
createElement(
CardDescription,
{ "data-testid": "description" },
"Description text",
),
),
),
);
const description = screen.getByTestId("description");
expect(description.tagName).toBe("P");
});
it("applies description classes", () => {
render(
createElement(
Card,
null,
createElement(
CardHeader,
null,
createElement(
CardDescription,
{ "data-testid": "description" },
"Description",
),
),
),
);
const description = screen.getByTestId("description");
expect(description.className).toContain("text-sm");
expect(description.className).toContain("text-foreground-secondary");
});
});
describe("CardContent", () => {
it("renders with default medium padding", () => {
render(
createElement(
Card,
null,
createElement(CardContent, { "data-testid": "content" }, "Content"),
),
);
const content = screen.getByTestId("content");
expect(content.className).toContain("p-6");
expect(content.className).toContain("pt-0");
});
it("applies small padding", () => {
render(
createElement(
Card,
null,
createElement(
CardContent,
{ padding: "sm", "data-testid": "content" },
"Content",
),
),
);
const content = screen.getByTestId("content");
expect(content.className).toContain("p-4");
expect(content.className).toContain("pt-0");
});
it("applies large padding", () => {
render(
createElement(
Card,
null,
createElement(
CardContent,
{ padding: "lg", "data-testid": "content" },
"Content",
),
),
);
const content = screen.getByTestId("content");
expect(content.className).toContain("p-8");
expect(content.className).toContain("pt-0");
});
it("applies no padding", () => {
render(
createElement(
Card,
null,
createElement(
CardContent,
{ padding: "none", "data-testid": "content" },
"Content",
),
),
);
const content = screen.getByTestId("content");
expect(content.className).toContain("p-0");
});
});
describe("CardFooter", () => {
it("renders with default medium padding", () => {
render(
createElement(
Card,
null,
createElement(CardFooter, { "data-testid": "footer" }, "Footer"),
),
);
const footer = screen.getByTestId("footer");
expect(footer.className).toContain("p-6");
expect(footer.className).toContain("pt-0");
});
it("applies small padding", () => {
render(
createElement(
Card,
null,
createElement(
CardFooter,
{ padding: "sm", "data-testid": "footer" },
"Footer",
),
),
);
const footer = screen.getByTestId("footer");
expect(footer.className).toContain("p-4");
expect(footer.className).toContain("pt-0");
});
it("applies large padding", () => {
render(
createElement(
Card,
null,
createElement(
CardFooter,
{ padding: "lg", "data-testid": "footer" },
"Footer",
),
),
);
const footer = screen.getByTestId("footer");
expect(footer.className).toContain("p-8");
expect(footer.className).toContain("pt-0");
});
it("applies flex layout classes", () => {
render(
createElement(
Card,
null,
createElement(CardFooter, { "data-testid": "footer" }, "Footer"),
),
);
const footer = screen.getByTestId("footer");
expect(footer.className).toContain("flex");
expect(footer.className).toContain("items-center");
});
});
describe("Custom className", () => {
it("merges custom className with Card variant classes", () => {
render(
createElement(
Card,
{
className: "custom-card",
"data-testid": "card",
},
"Custom",
),
);
const card = screen.getByTestId("card");
expect(card.className).toContain("custom-card");
expect(card.className).toContain("rounded-lg");
});
it("merges custom className with CardHeader", () => {
render(
createElement(
Card,
null,
createElement(
CardHeader,
{
className: "custom-header",
"data-testid": "header",
},
"Header",
),
),
);
const header = screen.getByTestId("header");
expect(header.className).toContain("custom-header");
expect(header.className).toContain("flex");
});
it("merges custom className with CardTitle", () => {
render(
createElement(
Card,
null,
createElement(
CardHeader,
null,
createElement(
CardTitle,
{
className: "custom-title",
"data-testid": "title",
},
"Title",
),
),
),
);
const title = screen.getByTestId("title");
expect(title.className).toContain("custom-title");
expect(title.className).toContain("font-semibold");
});
});
describe("Ref Forwarding", () => {
it("forwards ref to Card element", () => {
let ref: HTMLDivElement | null = null;
render(
createElement(
Card,
{
ref: (el: HTMLDivElement | null) => {
ref = el;
},
},
"Card",
),
);
expect(ref).toBeInstanceOf(HTMLDivElement);
expect(ref?.tagName).toBe("DIV");
});
it("forwards ref to CardHeader element", () => {
let ref: HTMLDivElement | null = null;
render(
createElement(
Card,
null,
createElement(
CardHeader,
{
ref: (el: HTMLDivElement | null) => {
ref = el;
},
},
"Header",
),
),
);
expect(ref).toBeInstanceOf(HTMLDivElement);
});
it("forwards ref to CardTitle element", () => {
let ref: HTMLHeadingElement | null = null;
render(
createElement(
Card,
null,
createElement(
CardHeader,
null,
createElement(
CardTitle,
{
ref: (el: HTMLHeadingElement | null) => {
ref = el;
},
},
"Title",
),
),
),
);
expect(ref).toBeInstanceOf(HTMLHeadingElement);
expect(ref?.tagName).toBe("H3");
});
it("forwards ref to CardDescription element", () => {
let ref: HTMLParagraphElement | null = null;
render(
createElement(
Card,
null,
createElement(
CardHeader,
null,
createElement(
CardDescription,
{
ref: (el: HTMLParagraphElement | null) => {
ref = el;
},
},
"Description",
),
),
),
);
expect(ref).toBeInstanceOf(HTMLParagraphElement);
});
it("forwards ref to CardContent element", () => {
let ref: HTMLDivElement | null = null;
render(
createElement(
Card,
null,
createElement(
CardContent,
{
ref: (el: HTMLDivElement | null) => {
ref = el;
},
},
"Content",
),
),
);
expect(ref).toBeInstanceOf(HTMLDivElement);
});
it("forwards ref to CardFooter element", () => {
let ref: HTMLDivElement | null = null;
render(
createElement(
Card,
null,
createElement(
CardFooter,
{
ref: (el: HTMLDivElement | null) => {
ref = el;
},
},
"Footer",
),
),
);
expect(ref).toBeInstanceOf(HTMLDivElement);
});
});
describe("HTML Attributes", () => {
it("passes through HTML attributes to Card", () => {
render(
createElement(
Card,
{
id: "test-card",
"data-testid": "card",
role: "article",
},
"Card",
),
);
const card = screen.getByTestId("card");
expect(card).toHaveAttribute("id", "test-card");
expect(card).toHaveAttribute("role", "article");
});
it("passes through onClick handler to Card", () => {
let clicked = false;
render(
createElement(
Card,
{
onClick: () => {
clicked = true;
},
"data-testid": "card",
},
"Clickable Card",
),
);
const card = screen.getByTestId("card");
card.click();
expect(clicked).toBe(true);
});
});
describe("Base Classes", () => {
it("Card always includes base classes", () => {
render(
createElement(
Card,
{
"data-testid": "card",
},
"Card",
),
);
const card = screen.getByTestId("card");
expect(card.className).toContain("relative");
expect(card.className).toContain("rounded-lg");
expect(card.className).toContain("transition-base");
});
it("CardHeader always includes base classes", () => {
render(
createElement(
Card,
null,
createElement(
CardHeader,
{
"data-testid": "header",
},
"Header",
),
),
);
const header = screen.getByTestId("header");
expect(header.className).toContain("flex");
expect(header.className).toContain("flex-col");
});
it("CardTitle always includes base classes", () => {
render(
createElement(
Card,
null,
createElement(
CardHeader,
null,
createElement(
CardTitle,
{
"data-testid": "title",
},
"Title",
),
),
),
);
const title = screen.getByTestId("title");
expect(title.className).toContain("text-2xl");
expect(title.className).toContain("font-semibold");
expect(title.className).toContain("leading-none");
expect(title.className).toContain("tracking-tight");
});
it("CardDescription always includes base classes", () => {
render(
createElement(
Card,
null,
createElement(
CardHeader,
null,
createElement(
CardDescription,
{
"data-testid": "description",
},
"Description",
),
),
),
);
const description = screen.getByTestId("description");
expect(description.className).toContain("text-sm");
expect(description.className).toContain("text-foreground-secondary");
expect(description.className).toContain("mt-1.5");
});
it("CardFooter always includes base classes", () => {
render(
createElement(
Card,
null,
createElement(
CardFooter,
{
"data-testid": "footer",
},
"Footer",
),
),
);
const footer = screen.getByTestId("footer");
expect(footer.className).toContain("flex");
expect(footer.className).toContain("items-center");
});
});
describe("Complex Composition", () => {
it("renders a complete card with all sections", () => {
render(
createElement(
Card,
{ variant: "elevated", "data-testid": "card" },
createElement(
CardHeader,
{ "data-testid": "header" },
createElement(CardTitle, null, "Card Title"),
createElement(CardDescription, null, "This is a card description"),
),
createElement(
CardContent,
{ "data-testid": "content" },
"Card content goes here",
),
createElement(
CardFooter,
{ "data-testid": "footer" },
"Footer actions",
),
),
);
const card = screen.getByTestId("card");
expect(card).toBeInTheDocument();
expect(screen.getByText("Card Title")).toBeInTheDocument();
expect(
screen.getByText("This is a card description"),
).toBeInTheDocument();
expect(screen.getByText("Card content goes here")).toBeInTheDocument();
expect(screen.getByText("Footer actions")).toBeInTheDocument();
});
it("maintains proper structure hierarchy", () => {
render(
createElement(
Card,
{ "data-testid": "card" },
createElement(
CardHeader,
{ "data-testid": "header" },
createElement(CardTitle, null, "Title"),
),
createElement(CardContent, { "data-testid": "content" }, "Content"),
),
);
const card = screen.getByTestId("card");
const header = screen.getByTestId("header");
const content = screen.getByTestId("content");
expect(card.contains(header)).toBe(true);
expect(card.contains(content)).toBe(true);
});
});
});

View file

@ -1,53 +1,160 @@
import { createElement, forwardRef, type HTMLAttributes } from 'react';
import { cn } from '@tpmjs/utils/cn';
import { cn } from "@tpmjs/utils/cn";
import { createElement, forwardRef } from "react";
import type {
CardContentProps,
CardDescriptionProps,
CardFooterProps,
CardHeaderProps,
CardProps,
CardTitleProps,
} from "./types";
import {
cardContentVariants,
cardDescriptionVariants,
cardFooterVariants,
cardHeaderVariants,
cardTitleVariants,
cardVariants,
} from "./variants";
export interface CardProps extends HTMLAttributes<HTMLDivElement> {}
/**
* Card component
*
* A flexible container component with multiple variants and sub-components.
* Built with .ts-only React using createElement.
*
* @example
* ```typescript
* import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@tpmjs/ui/Card/Card';
* import { createElement } from 'react';
*
* function MyComponent() {
* return createElement(Card, { variant: 'elevated' },
* createElement(CardHeader, null,
* createElement(CardTitle, null, 'Card Title'),
* createElement(CardDescription, null, 'Card description text')
* ),
* createElement(CardContent, null, 'Card content goes here'),
* createElement(CardFooter, null, 'Footer content')
* );
* }
* ```
*/
export const Card = forwardRef<HTMLDivElement, CardProps>(
({ className, variant = "default", padding = "none", ...props }, ref) => {
return createElement("div", {
ref,
className: cn(
cardVariants({
variant,
padding,
}),
className,
),
...props,
});
},
);
export const Card = forwardRef<HTMLDivElement, CardProps>(({ className, ...props }, ref) => {
return createElement('div', {
ref,
className: cn(
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
className
),
...props,
});
Card.displayName = "Card";
/**
* CardHeader component
*
* Header section of a card, typically contains title and description.
*/
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ className, padding = "md", ...props }, ref) => {
return createElement("div", {
ref,
className: cn(
cardHeaderVariants({
padding,
}),
className,
),
...props,
});
},
);
CardHeader.displayName = "CardHeader";
/**
* CardTitle component
*
* Title heading for a card.
*/
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
({ className, as = "h3", ...props }, ref) => {
return createElement(as, {
ref,
className: cn(cardTitleVariants(), className),
...props,
});
},
);
CardTitle.displayName = "CardTitle";
/**
* CardDescription component
*
* Description text for a card, typically placed below the title.
*/
export const CardDescription = forwardRef<
HTMLParagraphElement,
CardDescriptionProps
>(({ className, ...props }, ref) => {
return createElement("p", {
ref,
className: cn(cardDescriptionVariants(), className),
...props,
});
});
Card.displayName = 'Card';
CardDescription.displayName = "CardDescription";
export const CardHeader = forwardRef<HTMLDivElement, CardProps>(
({ className, ...props }, ref) => {
return createElement('div', {
ref,
className: cn('flex flex-col space-y-1.5 p-6', className),
...props,
});
}
/**
* CardContent component
*
* Main content area of a card.
*/
export const CardContent = forwardRef<HTMLDivElement, CardContentProps>(
({ className, padding = "md", ...props }, ref) => {
return createElement("div", {
ref,
className: cn(
cardContentVariants({
padding,
}),
className,
),
...props,
});
},
);
CardHeader.displayName = 'CardHeader';
CardContent.displayName = "CardContent";
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => {
return createElement('h3', {
ref,
className: cn('text-2xl font-semibold leading-none tracking-tight', className),
...props,
});
}
/**
* CardFooter component
*
* Footer section of a card, typically contains actions.
*/
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
({ className, padding = "md", ...props }, ref) => {
return createElement("div", {
ref,
className: cn(
cardFooterVariants({
padding,
}),
className,
),
...props,
});
},
);
CardTitle.displayName = 'CardTitle';
export const CardContent = forwardRef<HTMLDivElement, CardProps>(
({ className, ...props }, ref) => {
return createElement('div', {
ref,
className: cn('p-6 pt-0', className),
...props,
});
}
);
CardContent.displayName = 'CardContent';
CardFooter.displayName = "CardFooter";

View file

@ -0,0 +1,45 @@
import { borderRadius, spacing } from "../tokens";
/**
* Card component design tokens
* Maps global tokens to card-specific semantics
*/
export const cardTokens = {
/** Card padding for each size */
padding: {
none: spacing[0], // 0
sm: spacing[4], // 1rem (16px)
md: spacing[6], // 1.5rem (24px)
lg: spacing[8], // 2rem (32px)
},
/** Header/Content/Footer padding */
section: {
padding: {
none: spacing[0], // 0
sm: spacing[4], // 1rem (16px)
md: spacing[6], // 1.5rem (24px)
lg: spacing[8], // 2rem (32px)
},
gap: spacing[6], // 1.5rem (24px) - space between sections
},
/** Title spacing */
title: {
marginBottom: spacing[1.5], // 0.375rem (6px)
},
/** Description spacing */
description: {
marginTop: spacing[2], // 0.5rem (8px)
},
/** Border radius */
borderRadius: borderRadius.lg,
/** Elevation shadows */
elevation: {
default: "shadow-sm",
elevated: "shadow-md",
},
} as const;

View file

@ -0,0 +1,73 @@
import type { HTMLAttributes } from "react";
/**
* Card component props
*/
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
/**
* Visual variant of the card
* @default 'default'
*/
variant?: "default" | "elevated" | "outline" | "ghost";
/**
* Padding size for the card
* @default 'md'
*/
padding?: "none" | "sm" | "md" | "lg";
}
/**
* CardHeader component props
*/
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
/**
* Padding size for the header
* @default 'md'
*/
padding?: "none" | "sm" | "md" | "lg";
}
/**
* CardTitle component props
*/
export interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
/**
* Heading level
* @default 'h3'
*/
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
}
/**
* CardDescription component props
*/
export interface CardDescriptionProps
extends HTMLAttributes<HTMLParagraphElement> {}
/**
* CardContent component props
*/
export interface CardContentProps extends HTMLAttributes<HTMLDivElement> {
/**
* Padding size for the content
* @default 'md'
*/
padding?: "none" | "sm" | "md" | "lg";
}
/**
* CardFooter component props
*/
export interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {
/**
* Padding size for the footer
* @default 'md'
*/
padding?: "none" | "sm" | "md" | "lg";
}
/**
* Card ref type
*/
export type CardRef = HTMLDivElement;

View file

@ -0,0 +1,150 @@
import { createVariants } from "../system/variants";
/**
* Card variant definitions
* Uses custom variant system for type-safe class composition
*/
export const cardVariants = createVariants({
base: [
// Layout
"relative",
// Borders & Radius
"rounded-lg",
// Transitions
"transition-base",
].join(" "),
variants: {
variant: {
default: [
"border border-border",
"bg-card text-card-foreground",
"shadow-sm",
].join(" "),
elevated: [
"border border-border",
"bg-surface-elevated text-card-foreground",
"shadow-md",
].join(" "),
outline: [
"border-2 border-border",
"bg-transparent text-foreground",
].join(" "),
ghost: ["bg-transparent text-foreground"].join(" "),
},
padding: {
none: "p-0",
sm: "p-4",
md: "p-6",
lg: "p-8",
},
},
compoundVariants: [],
defaultVariants: {
variant: "default",
padding: "none", // Card itself has no padding, sections do
},
});
/**
* CardHeader variant definitions
*/
export const cardHeaderVariants = createVariants({
base: ["flex flex-col"].join(" "),
variants: {
padding: {
none: "p-0",
sm: "p-4",
md: "p-6",
lg: "p-8",
},
},
compoundVariants: [],
defaultVariants: {
padding: "md",
},
});
/**
* CardTitle variant definitions
*/
export const cardTitleVariants = createVariants({
base: [
"text-2xl font-semibold leading-none tracking-tight",
"text-foreground",
].join(" "),
variants: {},
compoundVariants: [],
defaultVariants: {},
});
/**
* CardDescription variant definitions
*/
export const cardDescriptionVariants = createVariants({
base: ["text-sm text-foreground-secondary", "mt-1.5"].join(" "),
variants: {},
compoundVariants: [],
defaultVariants: {},
});
/**
* CardContent variant definitions
*/
export const cardContentVariants = createVariants({
base: [
// Base content styles
].join(" "),
variants: {
padding: {
none: "p-0",
sm: "p-4 pt-0",
md: "p-6 pt-0",
lg: "p-8 pt-0",
},
},
compoundVariants: [],
defaultVariants: {
padding: "md",
},
});
/**
* CardFooter variant definitions
*/
export const cardFooterVariants = createVariants({
base: ["flex items-center"].join(" "),
variants: {
padding: {
none: "p-0",
sm: "p-4 pt-0",
md: "p-6 pt-0",
lg: "p-8 pt-0",
},
},
compoundVariants: [],
defaultVariants: {
padding: "md",
},
});

View file

@ -0,0 +1,424 @@
import { render, screen, waitFor } from "@testing-library/react";
import { createElement } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CodeBlock } from "./CodeBlock";
// Mock clipboard API
const mockClipboard = {
writeText: vi.fn(),
};
Object.defineProperty(navigator, "clipboard", {
value: mockClipboard,
writable: true,
configurable: true,
});
describe("CodeBlock", () => {
beforeEach(() => {
mockClipboard.writeText.mockClear();
});
describe("Rendering", () => {
it("renders a code block element", () => {
render(
createElement(CodeBlock, {
code: 'console.log("hello")',
"data-testid": "codeblock",
}),
);
const codeblock = screen.getByTestId("codeblock");
expect(codeblock).toBeInTheDocument();
expect(codeblock.tagName).toBe("DIV");
});
it("renders code content", () => {
render(createElement(CodeBlock, { code: "npm install package" }));
expect(screen.getByText("npm install package")).toBeInTheDocument();
});
it("renders code in a code element", () => {
render(
createElement(CodeBlock, {
code: "test code",
"data-testid": "codeblock",
}),
);
const codeblock = screen.getByTestId("codeblock");
const code = codeblock.querySelector("code");
expect(code).toBeInTheDocument();
expect(code?.textContent).toBe("test code");
});
it("preserves whitespace in code", () => {
const multilineCode = "line 1\n line 2\n line 3";
render(
createElement(CodeBlock, {
code: multilineCode,
"data-testid": "codeblock",
}),
);
const codeblock = screen.getByTestId("codeblock");
const code = codeblock.querySelector("code");
expect(code?.textContent).toBe(multilineCode);
});
});
describe("Language", () => {
it("sets data-language attribute", () => {
render(
createElement(CodeBlock, {
code: "const x = 5",
language: "javascript",
"data-testid": "codeblock",
}),
);
const codeblock = screen.getByTestId("codeblock");
const code = codeblock.querySelector("code");
expect(code).toHaveAttribute("data-language", "javascript");
});
it("defaults to text language", () => {
render(
createElement(CodeBlock, {
code: "plain text",
"data-testid": "codeblock",
}),
);
const codeblock = screen.getByTestId("codeblock");
const code = codeblock.querySelector("code");
expect(code).toHaveAttribute("data-language", "text");
});
it("supports different languages", () => {
const { rerender } = render(
createElement(CodeBlock, {
code: "code",
language: "python",
"data-testid": "codeblock",
}),
);
let codeblock = screen.getByTestId("codeblock");
let code = codeblock.querySelector("code");
expect(code).toHaveAttribute("data-language", "python");
rerender(
createElement(CodeBlock, {
code: "code",
language: "bash",
"data-testid": "codeblock",
}),
);
codeblock = screen.getByTestId("codeblock");
code = codeblock.querySelector("code");
expect(code).toHaveAttribute("data-language", "bash");
});
});
describe("Size Variants", () => {
it("applies small size", () => {
render(
createElement(CodeBlock, {
code: "code",
size: "sm",
"data-testid": "codeblock",
}),
);
const codeblock = screen.getByTestId("codeblock");
const code = codeblock.querySelector("code");
expect(code?.className).toContain("text-xs");
expect(code?.className).toContain("p-3");
});
it("applies medium size", () => {
render(
createElement(CodeBlock, {
code: "code",
size: "md",
"data-testid": "codeblock",
}),
);
const codeblock = screen.getByTestId("codeblock");
const code = codeblock.querySelector("code");
expect(code?.className).toContain("text-sm");
expect(code?.className).toContain("p-4");
});
it("applies large size", () => {
render(
createElement(CodeBlock, {
code: "code",
size: "lg",
"data-testid": "codeblock",
}),
);
const codeblock = screen.getByTestId("codeblock");
const code = codeblock.querySelector("code");
expect(code?.className).toContain("text-base");
expect(code?.className).toContain("p-6");
});
it("uses md size by default", () => {
render(
createElement(CodeBlock, { code: "code", "data-testid": "codeblock" }),
);
const codeblock = screen.getByTestId("codeblock");
const code = codeblock.querySelector("code");
expect(code?.className).toContain("text-sm");
expect(code?.className).toContain("p-4");
});
});
describe("Copy Button", () => {
it("shows copy button by default", () => {
render(createElement(CodeBlock, { code: "code" }));
expect(screen.getByTestId("copy-button")).toBeInTheDocument();
});
it("hides copy button when showCopy is false", () => {
render(createElement(CodeBlock, { code: "code", showCopy: false }));
expect(screen.queryByTestId("copy-button")).not.toBeInTheDocument();
});
it("copy button has correct aria-label", () => {
render(createElement(CodeBlock, { code: "code" }));
const button = screen.getByTestId("copy-button");
expect(button).toHaveAttribute("aria-label", "Copy code");
});
it("copy button is a button element", () => {
render(createElement(CodeBlock, { code: "code" }));
const button = screen.getByTestId("copy-button");
expect(button.tagName).toBe("BUTTON");
expect(button).toHaveAttribute("type", "button");
});
});
describe("Copy Functionality", () => {
it("copies code to clipboard when button clicked", async () => {
render(createElement(CodeBlock, { code: "test code" }));
const button = screen.getByTestId("copy-button");
button.click();
await waitFor(() => {
expect(mockClipboard.writeText).toHaveBeenCalledWith("test code");
});
});
it("shows check icon after successful copy", async () => {
render(
createElement(CodeBlock, {
code: "test code",
"data-testid": "codeblock",
}),
);
const button = screen.getByTestId("copy-button");
button.click();
await waitFor(() => {
const icon = button.querySelector("svg");
// Check icon has a different path than copy icon
const path = icon?.querySelector("path");
expect(path).toHaveAttribute(
"d",
"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z",
);
});
});
it('changes aria-label to "Copied!" after copy', async () => {
render(createElement(CodeBlock, { code: "test code" }));
const button = screen.getByTestId("copy-button");
button.click();
await waitFor(() => {
expect(button).toHaveAttribute("aria-label", "Copied!");
});
});
it("reverts to copy icon after 2 seconds", async () => {
vi.useFakeTimers();
try {
render(
createElement(CodeBlock, {
code: "test code",
"data-testid": "codeblock",
}),
);
const button = screen.getByTestId("copy-button");
button.click();
// Wait for copy to complete
await vi.waitFor(() => {
expect(mockClipboard.writeText).toHaveBeenCalled();
});
// Verify check icon appears
await vi.waitFor(() => {
const icon = button.querySelector("svg");
const path = icon?.querySelector("path");
expect(path).toHaveAttribute(
"d",
"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z",
);
});
// Fast forward 2 seconds
vi.advanceTimersByTime(2000);
// Verify copy icon is back
await vi.waitFor(() => {
const icon = button.querySelector("svg");
const path = icon?.querySelector("path");
expect(path).toHaveAttribute(
"d",
"M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z",
);
});
} finally {
vi.useRealTimers();
}
});
it("handles clipboard API errors gracefully", async () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
mockClipboard.writeText.mockRejectedValueOnce(
new Error("Clipboard not available"),
);
try {
render(createElement(CodeBlock, { code: "test code" }));
const button = screen.getByTestId("copy-button");
button.click();
await waitFor(
() => {
expect(consoleErrorSpy).toHaveBeenCalled();
},
{ timeout: 1000 },
);
} finally {
consoleErrorSpy.mockRestore();
}
});
});
describe("HTML Attributes", () => {
it("passes through id attribute", () => {
render(
createElement(CodeBlock, {
code: "code",
id: "codeblock-id",
"data-testid": "codeblock",
}),
);
const codeblock = screen.getByTestId("codeblock");
expect(codeblock).toHaveAttribute("id", "codeblock-id");
});
it("passes through data attributes", () => {
render(
createElement(CodeBlock, {
code: "code",
"data-custom": "test",
"data-testid": "codeblock",
}),
);
const codeblock = screen.getByTestId("codeblock");
expect(codeblock).toHaveAttribute("data-custom", "test");
});
});
describe("Custom className", () => {
it("merges custom className with variant classes", () => {
render(
createElement(CodeBlock, {
code: "code",
className: "custom-class",
"data-testid": "codeblock",
}),
);
const codeblock = screen.getByTestId("codeblock");
expect(codeblock.className).toContain("custom-class");
expect(codeblock.className).toContain("bg-zinc-900");
expect(codeblock.className).toContain("border");
});
});
describe("Ref Forwarding", () => {
it("forwards ref to container element", () => {
let ref: HTMLDivElement | null = null;
render(
createElement(CodeBlock, {
code: "code",
ref: (el: HTMLDivElement | null) => {
ref = el;
},
}),
);
expect(ref).toBeInstanceOf(HTMLDivElement);
expect(ref?.querySelector("code")).toBeInTheDocument();
});
});
describe("Base Classes", () => {
it("container includes base classes", () => {
render(
createElement(CodeBlock, { code: "code", "data-testid": "codeblock" }),
);
const codeblock = screen.getByTestId("codeblock");
expect(codeblock.className).toContain("relative");
expect(codeblock.className).toContain("bg-zinc-900");
expect(codeblock.className).toContain("border");
expect(codeblock.className).toContain("overflow-hidden");
});
it("code element includes base classes", () => {
render(
createElement(CodeBlock, { code: "code", "data-testid": "codeblock" }),
);
const codeblock = screen.getByTestId("codeblock");
const code = codeblock.querySelector("code");
expect(code?.className).toContain("block");
expect(code?.className).toContain("font-mono");
expect(code?.className).toContain("text-zinc-300");
expect(code?.className).toContain("overflow-x-auto");
expect(code?.className).toContain("whitespace-pre");
});
});
describe("Compound Scenarios", () => {
it("works correctly with large size and custom language", () => {
render(
createElement(CodeBlock, {
code: 'def hello():\n print("world")',
language: "python",
size: "lg",
"data-testid": "codeblock",
}),
);
const codeblock = screen.getByTestId("codeblock");
const code = codeblock.querySelector("code");
expect(code?.className).toContain("text-base");
expect(code?.className).toContain("p-6");
expect(code).toHaveAttribute("data-language", "python");
});
it("works correctly with no copy button and custom className", () => {
render(
createElement(CodeBlock, {
code: "test",
showCopy: false,
className: "my-custom-class",
"data-testid": "codeblock",
}),
);
const codeblock = screen.getByTestId("codeblock");
expect(codeblock.className).toContain("my-custom-class");
expect(screen.queryByTestId("copy-button")).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,98 @@
import { cn } from "@tpmjs/utils/cn";
import { createElement, forwardRef, useState } from "react";
import { Icon } from "../Icon/Icon";
import type { CodeBlockProps } from "./types";
import {
codeBlockCodeVariants,
codeBlockContainerVariants,
codeBlockCopyButtonVariants,
} from "./variants";
/**
* CodeBlock component
*
* Displays formatted code with optional copy functionality.
* Includes syntax-highlighted display and copy-to-clipboard button.
* Built with .ts-only React using createElement.
*
* @example
* ```typescript
* import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
* import { createElement } from 'react';
*
* function MyComponent() {
* return createElement(CodeBlock, {
* code: 'npm install @tpmjs/registry',
* language: 'bash',
* size: 'md',
* showCopy: true,
* });
* }
* ```
*/
export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
(
{
className,
code,
language = "text",
size = "md",
showCopy = true,
...props
},
ref,
) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
// Silently fail if clipboard API is not available
console.error("Failed to copy code:", err);
}
};
return createElement(
"div",
{
className: cn(codeBlockContainerVariants(), className),
ref,
...props,
},
[
createElement(
"code",
{
key: "code",
className: codeBlockCodeVariants({
size,
}),
"data-language": language,
},
code,
),
showCopy &&
createElement(
"button",
{
key: "copy-button",
type: "button",
className: codeBlockCopyButtonVariants(),
onClick: handleCopy,
"aria-label": copied ? "Copied!" : "Copy code",
"data-testid": "copy-button",
},
createElement(Icon, {
icon: copied ? "check" : "copy",
size: "sm",
}),
),
].filter(Boolean),
);
},
);
CodeBlock.displayName = "CodeBlock";

View file

@ -0,0 +1,24 @@
import { fontSize, spacing } from "../tokens";
/**
* CodeBlock component design tokens
* Maps global tokens to code block-specific semantics
*/
export const codeBlockTokens = {
/** Padding for each size */
padding: {
sm: spacing[3], // 0.75rem (12px)
md: spacing[4], // 1rem (16px)
lg: spacing[6], // 1.5rem (24px)
},
/** Font size for each size */
fontSize: {
sm: fontSize.xs, // 0.75rem (12px)
md: fontSize.sm, // 0.875rem (14px)
lg: fontSize.base, // 1rem (16px)
},
/** Border radius */
radius: spacing[2], // 0.5rem (8px)
} as const;

View file

@ -0,0 +1,35 @@
import type { HTMLAttributes } from "react";
/**
* CodeBlock component props
*/
export interface CodeBlockProps
extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
/**
* Code content to display
*/
code: string;
/**
* Programming language (for display/metadata only)
* @default 'text'
*/
language?: string;
/**
* Size variant
* @default 'md'
*/
size?: "sm" | "md" | "lg";
/**
* Whether to show copy button
* @default true
*/
showCopy?: boolean;
}
/**
* CodeBlock ref type
*/
export type CodeBlockRef = HTMLDivElement;

View file

@ -0,0 +1,85 @@
import { createVariants } from "../system/variants";
/**
* CodeBlock container variant definitions
*/
export const codeBlockContainerVariants = createVariants({
base: [
// Layout
"relative",
// Background
"bg-zinc-900",
// Border
"border border-zinc-800 rounded-lg",
// Overflow
"overflow-hidden",
].join(" "),
variants: {},
compoundVariants: [],
defaultVariants: {},
});
/**
* CodeBlock code element variant definitions
*/
export const codeBlockCodeVariants = createVariants({
base: [
// Display
"block",
// Font
"font-mono",
// Color
"text-zinc-300",
// Overflow
"overflow-x-auto",
// Whitespace
"whitespace-pre",
].join(" "),
variants: {
size: {
sm: "text-xs p-3", // 12px font, 12px padding
md: "text-sm p-4", // 14px font, 16px padding
lg: "text-base p-6", // 16px font, 24px padding
},
},
compoundVariants: [],
defaultVariants: {
size: "md",
},
});
/**
* CodeBlock copy button variant definitions
*/
export const codeBlockCopyButtonVariants = createVariants({
base: [
// Position
"absolute top-2 right-2",
// Display
"flex items-center justify-center",
// Size
"w-8 h-8",
// Background
"bg-zinc-800 hover:bg-zinc-700",
// Border
"border border-zinc-700 rounded",
// Color
"text-zinc-400 hover:text-zinc-100",
// Cursor
"cursor-pointer",
// Transition
"transition-colors duration-200",
].join(" "),
variants: {},
compoundVariants: [],
defaultVariants: {},
});

View file

@ -0,0 +1,298 @@
import { render, screen } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it } from "vitest";
import { Container } from "./Container";
describe("Container", () => {
describe("Rendering", () => {
it("renders a container element", () => {
render(
createElement(Container, { "data-testid": "container" }, "Content"),
);
const container = screen.getByTestId("container");
expect(container).toBeInTheDocument();
expect(container.tagName).toBe("DIV");
});
it("renders children content", () => {
render(createElement(Container, null, "Container content"));
expect(screen.getByText("Container content")).toBeInTheDocument();
});
});
describe("Size Variants", () => {
it("applies small size max-width", () => {
render(
createElement(
Container,
{ size: "sm", "data-testid": "container" },
"Small",
),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("max-w-screen-sm");
});
it("applies medium size max-width", () => {
render(
createElement(
Container,
{ size: "md", "data-testid": "container" },
"Medium",
),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("max-w-screen-md");
});
it("applies large size max-width", () => {
render(
createElement(
Container,
{ size: "lg", "data-testid": "container" },
"Large",
),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("max-w-screen-lg");
});
it("applies extra-large size max-width (default)", () => {
render(
createElement(
Container,
{ size: "xl", "data-testid": "container" },
"XLarge",
),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("max-w-screen-xl");
});
it("applies 2xl size max-width", () => {
render(
createElement(
Container,
{ size: "2xl", "data-testid": "container" },
"2XLarge",
),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("max-w-screen-2xl");
});
it("applies full width", () => {
render(
createElement(
Container,
{ size: "full", "data-testid": "container" },
"Full",
),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("max-w-full");
});
it("uses xl size by default", () => {
render(
createElement(Container, { "data-testid": "container" }, "Default"),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("max-w-screen-xl");
});
});
describe("Padding Variants", () => {
it("applies no padding", () => {
render(
createElement(
Container,
{ padding: "none", "data-testid": "container" },
"None",
),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("px-0");
});
it("applies small padding", () => {
render(
createElement(
Container,
{ padding: "sm", "data-testid": "container" },
"Small",
),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("px-4");
});
it("applies medium padding (default)", () => {
render(
createElement(
Container,
{ padding: "md", "data-testid": "container" },
"Medium",
),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("px-6");
});
it("applies large padding", () => {
render(
createElement(
Container,
{ padding: "lg", "data-testid": "container" },
"Large",
),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("px-8");
});
it("uses md padding by default", () => {
render(
createElement(Container, { "data-testid": "container" }, "Default"),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("px-6");
});
});
describe("Compound Scenarios", () => {
it("works correctly with small size and no padding", () => {
render(
createElement(
Container,
{
size: "sm",
padding: "none",
"data-testid": "container",
},
"Small no padding",
),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("max-w-screen-sm");
expect(container.className).toContain("px-0");
});
it("works correctly with full width and large padding", () => {
render(
createElement(
Container,
{
size: "full",
padding: "lg",
"data-testid": "container",
},
"Full large padding",
),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("max-w-full");
expect(container.className).toContain("px-8");
});
});
describe("HTML Attributes", () => {
it("passes through id attribute", () => {
render(
createElement(
Container,
{
id: "container-id",
"data-testid": "container",
},
"Container",
),
);
const container = screen.getByTestId("container");
expect(container).toHaveAttribute("id", "container-id");
});
it("passes through onClick handler", () => {
let clicked = false;
render(
createElement(
Container,
{
onClick: () => {
clicked = true;
},
"data-testid": "container",
},
"Clickable",
),
);
const container = screen.getByTestId("container");
container.click();
expect(clicked).toBe(true);
});
it("passes through aria attributes", () => {
render(
createElement(
Container,
{
"aria-label": "Main container",
"data-testid": "container",
},
"Container",
),
);
const container = screen.getByTestId("container");
expect(container).toHaveAttribute("aria-label", "Main container");
});
});
describe("Custom className", () => {
it("merges custom className with variant classes", () => {
render(
createElement(
Container,
{
className: "custom-class",
"data-testid": "container",
},
"Custom",
),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("custom-class");
expect(container.className).toContain("mx-auto");
expect(container.className).toContain("w-full");
});
});
describe("Ref Forwarding", () => {
it("forwards ref to container element", () => {
let ref: HTMLDivElement | null = null;
render(
createElement(
Container,
{
ref: (el: HTMLDivElement | null) => {
ref = el;
},
},
"Container",
),
);
expect(ref).toBeInstanceOf(HTMLDivElement);
expect(ref?.tagName).toBe("DIV");
});
});
describe("Base Classes", () => {
it("always includes base classes", () => {
render(
createElement(Container, { "data-testid": "container" }, "Container"),
);
const container = screen.getByTestId("container");
expect(container.className).toContain("mx-auto");
expect(container.className).toContain("w-full");
});
});
});

View file

@ -0,0 +1,47 @@
import { cn } from "@tpmjs/utils/cn";
import { createElement, forwardRef } from "react";
import type { ContainerProps } from "./types";
import { containerVariants } from "./variants";
/**
* Container component
*
* A layout wrapper component with responsive max-width constraints.
* Centers content and provides consistent horizontal padding.
* Built with .ts-only React using createElement.
*
* @example
* ```typescript
* import { Container } from '@tpmjs/ui/Container/Container';
* import { createElement } from 'react';
*
* function MyComponent() {
* return createElement(Container, {
* size: 'xl',
* padding: 'md',
* children: 'Page content goes here',
* });
* }
* ```
*/
export const Container = forwardRef<HTMLDivElement, ContainerProps>(
({ className, size = "xl", padding = "md", children, ...props }, ref) => {
return createElement(
"div",
{
className: cn(
containerVariants({
size,
padding,
}),
className,
),
ref,
...props,
},
children,
);
},
);
Container.displayName = "Container";

View file

@ -0,0 +1,25 @@
import { spacing } from "../tokens";
/**
* Container component design tokens
* Maps global tokens to container-specific semantics
*/
export const containerTokens = {
/** Maximum widths for each size */
maxWidth: {
sm: "640px", // max-w-screen-sm
md: "768px", // max-w-screen-md
lg: "1024px", // max-w-screen-lg
xl: "1280px", // max-w-screen-xl
"2xl": "1536px", // max-w-screen-2xl
full: "100%", // max-w-full
},
/** Horizontal padding for each size */
padding: {
none: spacing[0], // 0
sm: spacing[4], // 1rem (16px)
md: spacing[6], // 1.5rem (24px)
lg: spacing[8], // 2rem (32px)
},
} as const;

View file

@ -0,0 +1,23 @@
import type { HTMLAttributes } from "react";
/**
* Container component props
*/
export interface ContainerProps extends HTMLAttributes<HTMLDivElement> {
/**
* Maximum width of the container
* @default 'xl'
*/
size?: "sm" | "md" | "lg" | "xl" | "2xl" | "full";
/**
* Horizontal padding
* @default 'md'
*/
padding?: "none" | "sm" | "md" | "lg";
}
/**
* Container ref type
*/
export type ContainerRef = HTMLDivElement;

View file

@ -0,0 +1,37 @@
import { createVariants } from "../system/variants";
/**
* Container variant definitions
* Uses custom variant system for type-safe class composition
*/
export const containerVariants = createVariants({
base: [
// Layout
"mx-auto w-full",
].join(" "),
variants: {
size: {
sm: "max-w-screen-sm",
md: "max-w-screen-md",
lg: "max-w-screen-lg",
xl: "max-w-screen-xl",
"2xl": "max-w-screen-2xl",
full: "max-w-full",
},
padding: {
none: "px-0",
sm: "px-4",
md: "px-6",
lg: "px-8",
},
},
compoundVariants: [],
defaultVariants: {
size: "xl",
padding: "md",
},
});

View file

@ -0,0 +1,391 @@
import { render, screen } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it } from "vitest";
import { Header } from "./Header";
describe("Header", () => {
describe("Rendering", () => {
it("renders a header element", () => {
render(createElement(Header, { "data-testid": "header" }));
const header = screen.getByTestId("header");
expect(header).toBeInTheDocument();
expect(header.tagName).toBe("HEADER");
});
it("renders without title or actions", () => {
render(createElement(Header, { "data-testid": "header" }));
const header = screen.getByTestId("header");
expect(header).toBeInTheDocument();
expect(screen.queryByTestId("header-title")).not.toBeInTheDocument();
expect(screen.queryByTestId("header-actions")).not.toBeInTheDocument();
});
});
describe("Title", () => {
it("renders title when provided", () => {
render(createElement(Header, { title: "TPMJS Registry" }));
expect(screen.getByText("TPMJS Registry")).toBeInTheDocument();
});
it("renders title in title container", () => {
render(
createElement(Header, { title: "My App", "data-testid": "header" }),
);
const titleContainer = screen.getByTestId("header-title");
expect(titleContainer).toBeInTheDocument();
expect(titleContainer.textContent).toBe("My App");
});
it("title container has correct base classes", () => {
render(createElement(Header, { title: "App" }));
const titleContainer = screen.getByTestId("header-title");
expect(titleContainer.className).toContain("flex");
expect(titleContainer.className).toContain("items-center");
expect(titleContainer.className).toContain("font-semibold");
expect(titleContainer.className).toContain("text-zinc-100");
});
it("renders ReactNode as title", () => {
render(
createElement(Header, {
title: createElement(
"div",
{ "data-testid": "custom-title" },
"Custom",
),
}),
);
expect(screen.getByTestId("custom-title")).toBeInTheDocument();
});
});
describe("Actions", () => {
it("renders actions when provided", () => {
render(
createElement(Header, {
actions: createElement(
"button",
{ "data-testid": "action-btn" },
"Sign In",
),
}),
);
expect(screen.getByTestId("action-btn")).toBeInTheDocument();
});
it("renders actions in actions container", () => {
render(
createElement(Header, {
actions: "Actions content",
"data-testid": "header",
}),
);
const actionsContainer = screen.getByTestId("header-actions");
expect(actionsContainer).toBeInTheDocument();
expect(actionsContainer.textContent).toBe("Actions content");
});
it("actions container has correct base classes", () => {
render(createElement(Header, { actions: "Actions" }));
const actionsContainer = screen.getByTestId("header-actions");
expect(actionsContainer.className).toContain("flex");
expect(actionsContainer.className).toContain("items-center");
});
it("renders ReactNode as actions", () => {
render(
createElement(Header, {
actions: createElement(
"div",
{ "data-testid": "custom-actions" },
"Custom Actions",
),
}),
);
expect(screen.getByTestId("custom-actions")).toBeInTheDocument();
});
});
describe("Children", () => {
it("renders children when provided", () => {
render(createElement(Header, { children: "Header content" }));
expect(screen.getByText("Header content")).toBeInTheDocument();
});
it("renders children in children container", () => {
render(
createElement(Header, {
children: "Center content",
"data-testid": "header",
}),
);
const childrenContainer = screen.getByTestId("header-children");
expect(childrenContainer).toBeInTheDocument();
expect(childrenContainer.textContent).toBe("Center content");
});
it("children container has centering classes", () => {
render(createElement(Header, { children: "Content" }));
const childrenContainer = screen.getByTestId("header-children");
expect(childrenContainer.className).toContain("flex-1");
expect(childrenContainer.className).toContain("flex");
expect(childrenContainer.className).toContain("items-center");
expect(childrenContainer.className).toContain("justify-center");
});
it("renders ReactNode as children", () => {
render(
createElement(Header, {
children: createElement(
"nav",
{ "data-testid": "custom-nav" },
"Navigation",
),
}),
);
expect(screen.getByTestId("custom-nav")).toBeInTheDocument();
});
});
describe("Combined Content", () => {
it("renders title and actions together", () => {
render(
createElement(Header, {
title: "Title",
actions: "Actions",
}),
);
expect(screen.getByText("Title")).toBeInTheDocument();
expect(screen.getByText("Actions")).toBeInTheDocument();
});
it("renders title, children, and actions together", () => {
render(
createElement(Header, {
title: "Title",
children: "Center",
actions: "Actions",
}),
);
expect(screen.getByText("Title")).toBeInTheDocument();
expect(screen.getByText("Center")).toBeInTheDocument();
expect(screen.getByText("Actions")).toBeInTheDocument();
});
it("maintains correct layout with all content", () => {
render(
createElement(Header, {
title: "Left",
children: "Center",
actions: "Right",
"data-testid": "header",
}),
);
const header = screen.getByTestId("header");
expect(header.className).toContain("justify-between");
});
});
describe("Size Variants", () => {
it("applies small size to header", () => {
render(createElement(Header, { size: "sm", "data-testid": "header" }));
const header = screen.getByTestId("header");
expect(header.className).toContain("h-12");
expect(header.className).toContain("px-4");
});
it("applies medium size to header", () => {
render(createElement(Header, { size: "md", "data-testid": "header" }));
const header = screen.getByTestId("header");
expect(header.className).toContain("h-16");
expect(header.className).toContain("px-6");
});
it("applies large size to header", () => {
render(createElement(Header, { size: "lg", "data-testid": "header" }));
const header = screen.getByTestId("header");
expect(header.className).toContain("h-20");
expect(header.className).toContain("px-8");
});
it("uses md size by default", () => {
render(createElement(Header, { "data-testid": "header" }));
const header = screen.getByTestId("header");
expect(header.className).toContain("h-16");
expect(header.className).toContain("px-6");
});
it("applies size to title", () => {
render(createElement(Header, { title: "Title", size: "lg" }));
const titleContainer = screen.getByTestId("header-title");
expect(titleContainer.className).toContain("text-xl");
expect(titleContainer.className).toContain("gap-4");
});
it("applies size to actions", () => {
render(createElement(Header, { actions: "Actions", size: "sm" }));
const actionsContainer = screen.getByTestId("header-actions");
expect(actionsContainer.className).toContain("gap-2");
});
});
describe("Sticky Positioning", () => {
it("applies sticky classes when sticky is true", () => {
render(createElement(Header, { sticky: true, "data-testid": "header" }));
const header = screen.getByTestId("header");
expect(header.className).toContain("sticky");
expect(header.className).toContain("top-0");
expect(header.className).toContain("z-50");
});
it("does not apply sticky classes when sticky is false", () => {
render(createElement(Header, { sticky: false, "data-testid": "header" }));
const header = screen.getByTestId("header");
expect(header.className).not.toContain("sticky");
expect(header.className).not.toContain("top-0");
expect(header.className).not.toContain("z-50");
});
it("is not sticky by default", () => {
render(createElement(Header, { "data-testid": "header" }));
const header = screen.getByTestId("header");
expect(header.className).not.toContain("sticky");
});
});
describe("HTML Attributes", () => {
it("passes through id attribute", () => {
render(
createElement(Header, {
id: "header-id",
"data-testid": "header",
}),
);
const header = screen.getByTestId("header");
expect(header).toHaveAttribute("id", "header-id");
});
it("passes through data attributes", () => {
render(
createElement(Header, {
"data-custom": "test",
"data-testid": "header",
}),
);
const header = screen.getByTestId("header");
expect(header).toHaveAttribute("data-custom", "test");
});
it("passes through aria attributes", () => {
render(
createElement(Header, {
"aria-label": "Main header",
"data-testid": "header",
}),
);
const header = screen.getByTestId("header");
expect(header).toHaveAttribute("aria-label", "Main header");
});
});
describe("Custom className", () => {
it("merges custom className with variant classes", () => {
render(
createElement(Header, {
className: "custom-class",
"data-testid": "header",
}),
);
const header = screen.getByTestId("header");
expect(header.className).toContain("custom-class");
expect(header.className).toContain("flex");
expect(header.className).toContain("bg-black");
});
});
describe("Ref Forwarding", () => {
it("forwards ref to header element", () => {
let ref: HTMLElement | null = null;
render(
createElement(Header, {
ref: (el: HTMLElement | null) => {
ref = el;
},
}),
);
expect(ref).toBeInstanceOf(HTMLElement);
expect(ref?.tagName).toBe("HEADER");
});
});
describe("Base Classes", () => {
it("always includes base classes", () => {
render(createElement(Header, { "data-testid": "header" }));
const header = screen.getByTestId("header");
expect(header.className).toContain("flex");
expect(header.className).toContain("items-center");
expect(header.className).toContain("justify-between");
expect(header.className).toContain("w-full");
expect(header.className).toContain("bg-black");
expect(header.className).toContain("border-b");
expect(header.className).toContain("border-zinc-800");
});
});
describe("Compound Scenarios", () => {
it("works correctly with large size and sticky", () => {
render(
createElement(Header, {
title: "App",
actions: "Sign In",
size: "lg",
sticky: true,
"data-testid": "header",
}),
);
const header = screen.getByTestId("header");
expect(header.className).toContain("h-20");
expect(header.className).toContain("px-8");
expect(header.className).toContain("sticky");
expect(header.className).toContain("top-0");
expect(header.className).toContain("z-50");
});
it("works correctly with all content and custom className", () => {
render(
createElement(Header, {
title: "Title",
children: "Center",
actions: "Actions",
className: "my-header",
size: "sm",
"data-testid": "header",
}),
);
const header = screen.getByTestId("header");
expect(header.className).toContain("my-header");
expect(header.className).toContain("h-12");
expect(screen.getByText("Title")).toBeInTheDocument();
expect(screen.getByText("Center")).toBeInTheDocument();
expect(screen.getByText("Actions")).toBeInTheDocument();
});
it("works with complex ReactNode content", () => {
render(
createElement(Header, {
title: createElement("div", { className: "logo" }, [
createElement("img", { key: "img", src: "logo.png", alt: "Logo" }),
createElement("span", { key: "text" }, "TPMJS"),
]),
actions: createElement("div", { className: "nav" }, [
createElement("button", { key: "btn1" }, "Docs"),
createElement("button", { key: "btn2" }, "GitHub"),
]),
}),
);
expect(screen.getByText("TPMJS")).toBeInTheDocument();
expect(screen.getByText("Docs")).toBeInTheDocument();
expect(screen.getByText("GitHub")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,99 @@
import { cn } from "@tpmjs/utils/cn";
import { createElement, forwardRef } from "react";
import type { HeaderProps } from "./types";
import {
headerActionsVariants,
headerTitleVariants,
headerVariants,
} from "./variants";
/**
* Header component
*
* A flexible header bar with title and action slots.
* Supports sticky positioning and responsive sizing.
* Built with .ts-only React using createElement.
*
* @example
* ```typescript
* import { Header } from '@tpmjs/ui/Header/Header';
* import { Button } from '@tpmjs/ui/Button/Button';
* import { createElement } from 'react';
*
* function MyComponent() {
* return createElement(Header, {
* title: 'TPMJS Registry',
* actions: createElement(Button, { variant: 'ghost' }, 'Sign In'),
* size: 'md',
* sticky: true,
* });
* }
* ```
*/
export const Header = forwardRef<HTMLElement, HeaderProps>(
(
{
className,
title,
actions,
size = "md",
sticky = false,
children,
...props
},
ref,
) => {
return createElement(
"header",
{
className: cn(
headerVariants({
size,
sticky: sticky ? "true" : "false",
}),
className,
),
ref,
...props,
},
[
title &&
createElement(
"div",
{
key: "title",
className: headerTitleVariants({
size,
}),
"data-testid": "header-title",
},
title,
),
children &&
createElement(
"div",
{
key: "children",
className: "flex-1 flex items-center justify-center",
"data-testid": "header-children",
},
children,
),
actions &&
createElement(
"div",
{
key: "actions",
className: headerActionsVariants({
size,
}),
"data-testid": "header-actions",
},
actions,
),
].filter(Boolean),
);
},
);
Header.displayName = "Header";

View file

@ -0,0 +1,21 @@
import { spacing } from "../tokens";
/**
* Header component design tokens
* Maps global tokens to header-specific semantics
*/
export const headerTokens = {
/** Height for each size */
height: {
sm: "3rem", // 48px
md: "4rem", // 64px
lg: "5rem", // 80px
},
/** Horizontal padding for each size */
padding: {
sm: spacing[4], // 1rem (16px)
md: spacing[6], // 1.5rem (24px)
lg: spacing[8], // 2rem (32px)
},
} as const;

View file

@ -0,0 +1,34 @@
import type { HTMLAttributes, ReactNode } from "react";
/**
* Header component props
*/
export interface HeaderProps
extends Omit<HTMLAttributes<HTMLElement>, "title"> {
/**
* Title/logo content for the left side
*/
title?: ReactNode;
/**
* Actions/navigation for the right side
*/
actions?: ReactNode;
/**
* Size variant
* @default 'md'
*/
size?: "sm" | "md" | "lg";
/**
* Whether header is sticky
* @default false
*/
sticky?: boolean;
}
/**
* Header ref type
*/
export type HeaderRef = HTMLElement;

View file

@ -0,0 +1,88 @@
import { createVariants } from "../system/variants";
/**
* Header variant definitions
*/
export const headerVariants = createVariants({
base: [
// Display
"flex items-center justify-between",
// Width
"w-full",
// Background
"bg-black",
// Border
"border-b border-zinc-800",
].join(" "),
variants: {
size: {
sm: "h-12 px-4", // 48px height, 16px padding
md: "h-16 px-6", // 64px height, 24px padding
lg: "h-20 px-8", // 80px height, 32px padding
},
sticky: {
true: "sticky top-0 z-50",
false: "",
},
},
compoundVariants: [],
defaultVariants: {
size: "md",
sticky: false,
},
});
/**
* Header title variant definitions
*/
export const headerTitleVariants = createVariants({
base: [
// Display
"flex items-center",
// Font
"font-semibold",
// Color
"text-zinc-100",
].join(" "),
variants: {
size: {
sm: "text-base gap-2", // 16px
md: "text-lg gap-3", // 18px
lg: "text-xl gap-4", // 20px
},
},
compoundVariants: [],
defaultVariants: {
size: "md",
},
});
/**
* Header actions variant definitions
*/
export const headerActionsVariants = createVariants({
base: [
// Display
"flex items-center",
].join(" "),
variants: {
size: {
sm: "gap-2", // 8px
md: "gap-3", // 12px
lg: "gap-4", // 16px
},
},
compoundVariants: [],
defaultVariants: {
size: "md",
},
});

View file

@ -0,0 +1,276 @@
import { render, screen } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it } from "vitest";
import { Icon } from "./Icon";
describe("Icon", () => {
describe("Rendering", () => {
it("renders an SVG element", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
const icon = screen.getByTestId("icon");
expect(icon).toBeInTheDocument();
expect(icon.tagName).toBe("svg");
});
it("renders with correct viewBox", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
const icon = screen.getByTestId("icon");
expect(icon).toHaveAttribute("viewBox", "0 0 24 24");
});
it("renders with currentColor fill", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
const icon = screen.getByTestId("icon");
expect(icon).toHaveAttribute("fill", "currentColor");
});
it("includes path element", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
const icon = screen.getByTestId("icon");
const path = icon.querySelector("path");
expect(path).toBeInTheDocument();
});
it("is aria-hidden by default", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
const icon = screen.getByTestId("icon");
expect(icon).toHaveAttribute("aria-hidden", "true");
});
});
describe("Size Variants", () => {
it("applies small size", () => {
render(
createElement(Icon, {
icon: "check",
size: "sm",
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon");
expect(icon.className).toContain("w-4");
expect(icon.className).toContain("h-4");
});
it("applies medium size", () => {
render(
createElement(Icon, {
icon: "check",
size: "md",
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon");
expect(icon.className).toContain("w-5");
expect(icon.className).toContain("h-5");
});
it("applies large size", () => {
render(
createElement(Icon, {
icon: "check",
size: "lg",
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon");
expect(icon.className).toContain("w-6");
expect(icon.className).toContain("h-6");
});
it("uses md size by default", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
const icon = screen.getByTestId("icon");
expect(icon.className).toContain("w-5");
expect(icon.className).toContain("h-5");
});
});
describe("Icon Variants", () => {
it("renders copy icon", () => {
render(createElement(Icon, { icon: "copy", "data-testid": "icon" }));
const icon = screen.getByTestId("icon");
const path = icon.querySelector("path");
expect(path).toHaveAttribute(
"d",
"M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z",
);
});
it("renders externalLink icon", () => {
render(
createElement(Icon, { icon: "externalLink", "data-testid": "icon" }),
);
const icon = screen.getByTestId("icon");
const path = icon.querySelector("path");
expect(path).toHaveAttribute(
"d",
"M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z",
);
});
it("renders github icon", () => {
render(createElement(Icon, { icon: "github", "data-testid": "icon" }));
const icon = screen.getByTestId("icon");
const path = icon.querySelector("path");
expect(path?.getAttribute("d")).toContain("M12 2C6.477 2 2 6.477 2 12");
});
it("renders check icon", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
const icon = screen.getByTestId("icon");
const path = icon.querySelector("path");
expect(path).toHaveAttribute(
"d",
"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z",
);
});
it("renders x icon", () => {
render(createElement(Icon, { icon: "x", "data-testid": "icon" }));
const icon = screen.getByTestId("icon");
const path = icon.querySelector("path");
expect(path).toHaveAttribute(
"d",
"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z",
);
});
it("renders chevronDown icon", () => {
render(
createElement(Icon, { icon: "chevronDown", "data-testid": "icon" }),
);
const icon = screen.getByTestId("icon");
const path = icon.querySelector("path");
expect(path).toHaveAttribute(
"d",
"M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z",
);
});
});
describe("SVG Attributes", () => {
it("passes through className", () => {
render(
createElement(Icon, {
icon: "check",
className: "custom-class",
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon");
expect(icon.className).toContain("custom-class");
});
it("merges custom className with variant classes", () => {
render(
createElement(Icon, {
icon: "check",
className: "text-red-500",
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon");
expect(icon.className).toContain("text-red-500");
expect(icon.className).toContain("inline-block");
expect(icon.className).toContain("fill-current");
});
it("allows custom aria-hidden", () => {
render(
createElement(Icon, {
icon: "check",
"aria-hidden": false,
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon");
expect(icon).toHaveAttribute("aria-hidden", "false");
});
it("passes through aria-label", () => {
render(
createElement(Icon, {
icon: "check",
"aria-label": "Success",
"aria-hidden": false,
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon");
expect(icon).toHaveAttribute("aria-label", "Success");
});
it("passes through id attribute", () => {
render(
createElement(Icon, {
icon: "check",
id: "icon-id",
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon");
expect(icon).toHaveAttribute("id", "icon-id");
});
});
describe("Ref Forwarding", () => {
it("forwards ref to SVG element", () => {
let ref: SVGSVGElement | null = null;
render(
createElement(Icon, {
icon: "check",
ref: (el: SVGSVGElement | null) => {
ref = el;
},
}),
);
expect(ref).toBeInstanceOf(SVGSVGElement);
expect(ref?.tagName).toBe("svg");
});
});
describe("Base Classes", () => {
it("always includes base classes", () => {
render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
const icon = screen.getByTestId("icon");
expect(icon.className).toContain("inline-block");
expect(icon.className).toContain("fill-current");
});
});
describe("Compound Scenarios", () => {
it("works correctly with custom size and className", () => {
render(
createElement(Icon, {
icon: "github",
size: "lg",
className: "text-zinc-400 hover:text-zinc-100",
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon");
expect(icon.className).toContain("w-6");
expect(icon.className).toContain("h-6");
expect(icon.className).toContain("text-zinc-400");
expect(icon.className).toContain("hover:text-zinc-100");
});
it("works correctly with aria attributes and custom size", () => {
render(
createElement(Icon, {
icon: "externalLink",
size: "sm",
"aria-label": "Opens in new tab",
"aria-hidden": false,
"data-testid": "icon",
}),
);
const icon = screen.getByTestId("icon");
expect(icon.className).toContain("w-4");
expect(icon.className).toContain("h-4");
expect(icon).toHaveAttribute("aria-label", "Opens in new tab");
expect(icon).toHaveAttribute("aria-hidden", "false");
});
});
});

View file

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

View file

@ -0,0 +1,41 @@
/**
* Icon definitions
* SVG path data for available icons
*/
export const icons = {
copy: {
viewBox: "0 0 24 24",
path: "M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z",
},
externalLink: {
viewBox: "0 0 24 24",
path: "M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z",
},
github: {
viewBox: "0 0 24 24",
path: "M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.603-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z",
},
check: {
viewBox: "0 0 24 24",
path: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z",
},
x: {
viewBox: "0 0 24 24",
path: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z",
},
chevronDown: {
viewBox: "0 0 24 24",
path: "M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z",
},
sun: {
viewBox: "0 0 24 24",
path: "M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z",
},
moon: {
viewBox: "0 0 24 24",
path: "M9.37 5.51c-.18.64-.27 1.31-.27 1.99 0 4.08 3.32 7.4 7.4 7.4.68 0 1.35-.09 1.99-.27C17.45 17.19 14.93 19 12 19c-3.86 0-7-3.14-7-7 0-2.93 1.81-5.45 4.37-6.49zM12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z",
},
} as const;
export type IconName = keyof typeof icons;

View file

@ -0,0 +1,12 @@
/**
* Icon component design tokens
* Maps size variants to pixel dimensions
*/
export const iconTokens = {
/** Icon sizes in pixels */
size: {
sm: "16px", // 1rem
md: "20px", // 1.25rem
lg: "24px", // 1.5rem
},
} as const;

View file

@ -0,0 +1,24 @@
import type { SVGAttributes } from "react";
import type { IconName } from "./icons";
/**
* Icon component props
*/
export interface IconProps
extends Omit<SVGAttributes<SVGSVGElement>, "children"> {
/**
* Icon to display
*/
icon: IconName;
/**
* Size of the icon
* @default 'md'
*/
size?: "sm" | "md" | "lg";
}
/**
* Icon ref type
*/
export type IconRef = SVGSVGElement;

View file

@ -0,0 +1,28 @@
import { createVariants } from "../system/variants";
/**
* Icon variant definitions
* Uses custom variant system for type-safe class composition
*/
export const iconVariants = createVariants({
base: [
// Display
"inline-block",
// Fill
"fill-current",
].join(" "),
variants: {
size: {
sm: "w-4 h-4", // 16px
md: "w-5 h-5", // 20px
lg: "w-6 h-6", // 24px
},
},
compoundVariants: [],
defaultVariants: {
size: "md",
},
});

View file

@ -0,0 +1,552 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it, vi } from "vitest";
import { Input } from "./Input";
describe("Input", () => {
describe("Rendering", () => {
it("renders an input element", () => {
render(createElement(Input, { "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input).toBeInTheDocument();
expect(input.tagName).toBe("INPUT");
});
it('renders with default type="text"', () => {
render(createElement(Input, { "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("type", "text");
});
});
describe("States", () => {
it("applies default state classes", () => {
render(
createElement(Input, { state: "default", "data-testid": "input" }),
);
const input = screen.getByTestId("input");
expect(input.className).toContain("border-border");
expect(input.className).toContain("text-foreground");
});
it("applies error state classes", () => {
render(createElement(Input, { state: "error", "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input.className).toContain("border-error");
});
it("applies success state classes", () => {
render(
createElement(Input, { state: "success", "data-testid": "input" }),
);
const input = screen.getByTestId("input");
expect(input.className).toContain("border-success");
});
it("sets aria-invalid on error state", () => {
render(createElement(Input, { state: "error", "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("aria-invalid", "true");
});
it("does not set aria-invalid on default state", () => {
render(
createElement(Input, { state: "default", "data-testid": "input" }),
);
const input = screen.getByTestId("input");
expect(input).not.toHaveAttribute("aria-invalid");
});
it("does not set aria-invalid on success state", () => {
render(
createElement(Input, { state: "success", "data-testid": "input" }),
);
const input = screen.getByTestId("input");
expect(input).not.toHaveAttribute("aria-invalid");
});
});
describe("Sizes", () => {
it("applies small size classes", () => {
render(createElement(Input, { size: "sm", "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input.className).toContain("h-9");
expect(input.className).toContain("px-3");
expect(input.className).toContain("text-sm");
});
it("applies medium size classes (default)", () => {
render(createElement(Input, { size: "md", "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input.className).toContain("h-10");
expect(input.className).toContain("px-3");
expect(input.className).toContain("text-base");
});
it("applies large size classes", () => {
render(createElement(Input, { size: "lg", "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input.className).toContain("h-11");
expect(input.className).toContain("px-4");
expect(input.className).toContain("text-lg");
});
});
describe("Full Width", () => {
it("is full width by default", () => {
render(createElement(Input, { "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input.className).toContain("w-full");
});
it("applies full width when explicitly true", () => {
render(createElement(Input, { fullWidth: true, "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input.className).toContain("w-full");
});
it("does not apply full width when false", () => {
render(
createElement(Input, { fullWidth: false, "data-testid": "input" }),
);
const input = screen.getByTestId("input");
expect(input.className).toContain("w-auto");
});
});
describe("Input Types", () => {
it('renders with type="email"', () => {
render(createElement(Input, { type: "email", "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("type", "email");
});
it('renders with type="password"', () => {
render(
createElement(Input, { type: "password", "data-testid": "input" }),
);
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("type", "password");
});
it('renders with type="number"', () => {
render(createElement(Input, { type: "number", "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("type", "number");
});
it('renders with type="tel"', () => {
render(createElement(Input, { type: "tel", "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("type", "tel");
});
it('renders with type="url"', () => {
render(createElement(Input, { type: "url", "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("type", "url");
});
it('renders with type="search"', () => {
render(createElement(Input, { type: "search", "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("type", "search");
});
it('renders with type="date"', () => {
render(createElement(Input, { type: "date", "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("type", "date");
});
it('renders with type="time"', () => {
render(createElement(Input, { type: "time", "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("type", "time");
});
it('renders with type="file"', () => {
render(createElement(Input, { type: "file", "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("type", "file");
});
});
describe("Disabled State", () => {
it("is not disabled by default", () => {
render(createElement(Input, { "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input).not.toBeDisabled();
});
it("disables input when disabled prop is true", () => {
render(createElement(Input, { disabled: true, "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input).toBeDisabled();
});
it("applies disabled opacity class", () => {
render(createElement(Input, { disabled: true, "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input.className).toContain("disabled:opacity-50");
expect(input.className).toContain("disabled:cursor-not-allowed");
});
});
describe("Placeholder", () => {
it("renders with placeholder text", () => {
render(
createElement(Input, {
placeholder: "Enter your email",
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("placeholder", "Enter your email");
});
it("applies placeholder styling classes", () => {
render(
createElement(Input, {
placeholder: "Placeholder text",
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input.className).toContain("placeholder:text-foreground-tertiary");
});
});
describe("Value and onChange", () => {
it("renders with initial value", () => {
render(
createElement(Input, {
value: "test value",
onChange: () => {},
"data-testid": "input",
}),
);
const input = screen.getByTestId("input") as HTMLInputElement;
expect(input.value).toBe("test value");
});
it("calls onChange when input value changes", () => {
const handleChange = vi.fn();
render(
createElement(Input, {
onChange: handleChange,
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
fireEvent.change(input, { target: { value: "new value" } });
expect(handleChange).toHaveBeenCalledTimes(1);
});
it("updates value correctly when typing", () => {
render(
createElement(Input, {
defaultValue: "",
"data-testid": "input",
}),
);
const input = screen.getByTestId("input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "typed text" } });
expect(input.value).toBe("typed text");
});
});
describe("HTML Attributes", () => {
it("passes through id attribute", () => {
render(
createElement(Input, {
id: "test-input",
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("id", "test-input");
});
it("passes through name attribute", () => {
render(
createElement(Input, {
name: "email",
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("name", "email");
});
it("passes through required attribute", () => {
render(
createElement(Input, {
required: true,
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("required");
});
it("passes through maxLength attribute", () => {
render(
createElement(Input, {
maxLength: 100,
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("maxLength", "100");
});
it("passes through pattern attribute", () => {
render(
createElement(Input, {
pattern: "[0-9]*",
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("pattern", "[0-9]*");
});
it("passes through min and max for number input", () => {
render(
createElement(Input, {
type: "number",
min: 0,
max: 100,
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("min", "0");
expect(input).toHaveAttribute("max", "100");
});
it("passes through step for number input", () => {
render(
createElement(Input, {
type: "number",
step: 0.01,
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("step", "0.01");
});
it("passes through autoComplete attribute", () => {
render(
createElement(Input, {
autoComplete: "email",
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("autoComplete", "email");
});
it("passes through autoFocus attribute", () => {
render(
createElement(Input, {
autoFocus: true,
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input).toHaveFocus();
});
});
describe("ARIA Attributes", () => {
it("passes through aria-label", () => {
render(
createElement(Input, {
"aria-label": "Email address",
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("aria-label", "Email address");
});
it("passes through aria-describedby", () => {
render(
createElement(Input, {
"aria-describedby": "helper-text",
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("aria-describedby", "helper-text");
});
it("passes through aria-labelledby", () => {
render(
createElement(Input, {
"aria-labelledby": "label-id",
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("aria-labelledby", "label-id");
});
});
describe("Custom className", () => {
it("merges custom className with variant classes", () => {
render(
createElement(Input, {
className: "custom-input",
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input.className).toContain("custom-input");
expect(input.className).toContain("rounded-md");
expect(input.className).toContain("border");
});
});
describe("Ref Forwarding", () => {
it("forwards ref to input element", () => {
let ref: HTMLInputElement | null = null;
render(
createElement(Input, {
ref: (el: HTMLInputElement | null) => {
ref = el;
},
}),
);
expect(ref).toBeInstanceOf(HTMLInputElement);
expect(ref?.tagName).toBe("INPUT");
});
it("can focus input through ref", () => {
let ref: HTMLInputElement | null = null;
render(
createElement(Input, {
ref: (el: HTMLInputElement | null) => {
ref = el;
},
"data-testid": "input",
}),
);
ref?.focus();
expect(screen.getByTestId("input")).toHaveFocus();
});
});
describe("Base Classes", () => {
it("always includes base classes", () => {
render(createElement(Input, { "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input.className).toContain("flex");
expect(input.className).toContain("w-full");
expect(input.className).toContain("font-sans");
expect(input.className).toContain("rounded-md");
expect(input.className).toContain("border");
expect(input.className).toContain("bg-background");
});
it("includes transition classes", () => {
render(createElement(Input, { "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input.className).toContain("transition-base");
});
it("includes focus ring classes", () => {
render(createElement(Input, { "data-testid": "input" }));
const input = screen.getByTestId("input");
expect(input.className).toContain("focus-ring");
});
});
describe("Event Handlers", () => {
it("calls onFocus when input is focused", () => {
const handleFocus = vi.fn();
render(
createElement(Input, {
onFocus: handleFocus,
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
fireEvent.focus(input);
expect(handleFocus).toHaveBeenCalledTimes(1);
});
it("calls onBlur when input loses focus", () => {
const handleBlur = vi.fn();
render(
createElement(Input, {
onBlur: handleBlur,
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
fireEvent.focus(input);
fireEvent.blur(input);
expect(handleBlur).toHaveBeenCalledTimes(1);
});
it("calls onKeyDown when key is pressed", () => {
const handleKeyDown = vi.fn();
render(
createElement(Input, {
onKeyDown: handleKeyDown,
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
fireEvent.keyDown(input, { key: "Enter" });
expect(handleKeyDown).toHaveBeenCalledTimes(1);
});
});
describe("Compound Scenarios", () => {
it("works correctly with error state and small size", () => {
render(
createElement(Input, {
state: "error",
size: "sm",
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input.className).toContain("border-error");
expect(input.className).toContain("h-9");
expect(input).toHaveAttribute("aria-invalid", "true");
});
it("works correctly with success state and large size", () => {
render(
createElement(Input, {
state: "success",
size: "lg",
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input.className).toContain("border-success");
expect(input.className).toContain("h-11");
});
it("works correctly with disabled state and error state", () => {
render(
createElement(Input, {
disabled: true,
state: "error",
"data-testid": "input",
}),
);
const input = screen.getByTestId("input");
expect(input).toBeDisabled();
expect(input.className).toContain("border-error");
expect(input).toHaveAttribute("aria-invalid", "true");
});
});
});

View file

@ -0,0 +1,58 @@
import { cn } from "@tpmjs/utils/cn";
import { createElement, forwardRef } from "react";
import type { InputProps } from "./types";
import { inputVariants } from "./variants";
/**
* Input component
*
* A versatile input component with multiple states, sizes, and full HTML input support.
* Built with .ts-only React using createElement.
*
* @example
* ```typescript
* import { Input } from '@tpmjs/ui/Input/Input';
* import { createElement } from 'react';
*
* function MyComponent() {
* return createElement(Input, {
* type: 'email',
* placeholder: 'Enter your email',
* state: 'default',
* size: 'md',
* });
* }
* ```
*/
export const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
className,
type = "text",
state = "default",
size = "md",
fullWidth = true,
disabled = false,
...props
},
ref,
) => {
return createElement("input", {
type,
className: cn(
inputVariants({
state,
size,
fullWidth: fullWidth ? "true" : "false",
}),
className,
),
ref,
disabled,
"aria-invalid": state === "error" ? "true" : undefined,
...props,
});
},
);
Input.displayName = "Input";

View file

@ -0,0 +1,44 @@
import { borderRadius, spacing } from "../tokens";
/**
* Input component design tokens
* Maps global tokens to input-specific semantics
*/
export const inputTokens = {
/** Input heights for each size */
height: {
sm: spacing[9], // 2.25rem (36px)
md: spacing[10], // 2.5rem (40px)
lg: spacing[11], // 2.75rem (44px)
},
/** Horizontal padding for each size */
padding: {
x: {
sm: spacing[3], // 0.75rem (12px)
md: spacing[3], // 0.75rem (12px)
lg: spacing[4], // 1rem (16px)
},
y: {
sm: spacing[2], // 0.5rem (8px)
md: spacing[2.5], // 0.625rem (10px)
lg: spacing[3], // 0.75rem (12px)
},
},
/** Font sizes for each size */
fontSize: {
sm: "0.875rem", // 14px
md: "1rem", // 16px
lg: "1.125rem", // 18px
},
/** Border radius */
borderRadius: borderRadius.md,
/** Border width */
borderWidth: "1px",
/** Transition duration */
transitionDuration: "200ms",
} as const;

View file

@ -0,0 +1,30 @@
import type { InputHTMLAttributes } from "react";
/**
* Input component props
*/
export interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
/**
* Visual state of the input
* @default 'default'
*/
state?: "default" | "error" | "success";
/**
* Size of the input
* @default 'md'
*/
size?: "sm" | "md" | "lg";
/**
* Full width input
* @default false
*/
fullWidth?: boolean;
}
/**
* Input ref type
*/
export type InputRef = HTMLInputElement;

View file

@ -0,0 +1,71 @@
import { createVariants } from "../system/variants";
/**
* Input variant definitions
* Uses custom variant system for type-safe class composition
*/
export const inputVariants = createVariants({
base: [
// Layout
"flex w-full",
// Typography
"font-sans",
// Borders & Radius
"rounded-md border",
// Background
"bg-background",
// Transitions
"transition-base",
// Focus
"focus-ring",
// Placeholder
"placeholder:text-foreground-tertiary",
// File input
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
// Disabled state
"disabled:cursor-not-allowed disabled:opacity-50",
].join(" "),
variants: {
state: {
default: [
"border-border text-foreground",
"hover:border-border-strong",
"focus:border-primary",
].join(" "),
error: [
"border-error text-foreground",
"hover:border-error",
"focus:border-error",
"focus:ring-error/20",
].join(" "),
success: [
"border-success text-foreground",
"hover:border-success",
"focus:border-success",
"focus:ring-success/20",
].join(" "),
},
size: {
sm: "h-9 px-3 py-2 text-sm",
md: "h-10 px-3 py-2.5 text-base",
lg: "h-11 px-4 py-3 text-lg",
},
fullWidth: {
true: "w-full",
false: "w-auto",
},
},
compoundVariants: [],
defaultVariants: {
state: "default",
size: "md",
fullWidth: "true",
},
});

View file

@ -0,0 +1,467 @@
import { render, screen } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it } from "vitest";
import { Label } from "./Label";
describe("Label", () => {
describe("Rendering", () => {
it("renders a label element", () => {
render(createElement(Label, { "data-testid": "label" }, "Label text"));
const label = screen.getByTestId("label");
expect(label).toBeInTheDocument();
expect(label.tagName).toBe("LABEL");
});
it("renders children text", () => {
render(createElement(Label, null, "Email Address"));
expect(screen.getByText("Email Address")).toBeInTheDocument();
});
});
describe("Sizes", () => {
it("applies small size classes", () => {
render(
createElement(
Label,
{
size: "sm",
"data-testid": "label",
},
"Small label",
),
);
const label = screen.getByTestId("label");
expect(label.className).toContain("text-sm");
});
it("applies medium size classes (default)", () => {
render(
createElement(
Label,
{
size: "md",
"data-testid": "label",
},
"Medium label",
),
);
const label = screen.getByTestId("label");
expect(label.className).toContain("text-base");
});
it("applies large size classes", () => {
render(
createElement(
Label,
{
size: "lg",
"data-testid": "label",
},
"Large label",
),
);
const label = screen.getByTestId("label");
expect(label.className).toContain("text-lg");
});
it("uses medium size by default", () => {
render(
createElement(
Label,
{
"data-testid": "label",
},
"Default label",
),
);
const label = screen.getByTestId("label");
expect(label.className).toContain("text-base");
});
});
describe("Required Indicator", () => {
it("does not show required indicator by default", () => {
render(createElement(Label, null, "Label"));
expect(screen.queryByText("*")).not.toBeInTheDocument();
});
it("shows required indicator when required is true", () => {
render(
createElement(
Label,
{
required: true,
},
"Required field",
),
);
expect(screen.getByText("*")).toBeInTheDocument();
});
it("hides required indicator from screen readers", () => {
render(
createElement(
Label,
{
required: true,
"data-testid": "label",
},
"Required field",
),
);
const asterisk = screen.getByText("*");
expect(asterisk).toHaveAttribute("aria-hidden", "true");
});
it("applies error color to required indicator", () => {
render(
createElement(
Label,
{
required: true,
},
"Required field",
),
);
const asterisk = screen.getByText("*");
expect(asterisk.className).toContain("text-error");
});
it("applies proper spacing to required indicator", () => {
render(
createElement(
Label,
{
required: true,
},
"Required field",
),
);
const asterisk = screen.getByText("*");
expect(asterisk.className).toContain("ml-1");
});
});
describe("Disabled State", () => {
it("is not disabled by default", () => {
render(
createElement(
Label,
{
"data-testid": "label",
},
"Label",
),
);
const label = screen.getByTestId("label");
// Should not have the direct opacity-50 class (peer-disabled:opacity-50 is okay)
expect(label.className).toContain("peer-disabled:opacity-50");
expect(label.className.split(" ")).not.toContain("opacity-50");
});
it("applies disabled classes when disabled is true", () => {
render(
createElement(
Label,
{
disabled: true,
"data-testid": "label",
},
"Disabled label",
),
);
const label = screen.getByTestId("label");
expect(label.className).toContain("opacity-50");
expect(label.className).toContain("cursor-not-allowed");
});
});
describe("HTML Attributes", () => {
it("associates with input using htmlFor", () => {
render(
createElement(
Label,
{
htmlFor: "email-input",
"data-testid": "label",
},
"Email",
),
);
const label = screen.getByTestId("label");
expect(label).toHaveAttribute("for", "email-input");
});
it("passes through id attribute", () => {
render(
createElement(
Label,
{
id: "label-id",
"data-testid": "label",
},
"Label",
),
);
const label = screen.getByTestId("label");
expect(label).toHaveAttribute("id", "label-id");
});
it("passes through className", () => {
render(
createElement(
Label,
{
className: "custom-label",
"data-testid": "label",
},
"Custom",
),
);
const label = screen.getByTestId("label");
expect(label.className).toContain("custom-label");
});
it("passes through onClick handler", () => {
let clicked = false;
render(
createElement(
Label,
{
onClick: () => {
clicked = true;
},
"data-testid": "label",
},
"Clickable",
),
);
const label = screen.getByTestId("label");
label.click();
expect(clicked).toBe(true);
});
});
describe("ARIA Attributes", () => {
it("passes through aria-label", () => {
render(
createElement(
Label,
{
"aria-label": "Field label",
"data-testid": "label",
},
"Label",
),
);
const label = screen.getByTestId("label");
expect(label).toHaveAttribute("aria-label", "Field label");
});
it("passes through aria-describedby", () => {
render(
createElement(
Label,
{
"aria-describedby": "description-id",
"data-testid": "label",
},
"Label",
),
);
const label = screen.getByTestId("label");
expect(label).toHaveAttribute("aria-describedby", "description-id");
});
});
describe("Custom className", () => {
it("merges custom className with base classes", () => {
render(
createElement(
Label,
{
className: "custom-class",
"data-testid": "label",
},
"Label",
),
);
const label = screen.getByTestId("label");
expect(label.className).toContain("custom-class");
expect(label.className).toContain("font-medium");
expect(label.className).toContain("text-foreground");
});
});
describe("Ref Forwarding", () => {
it("forwards ref to label element", () => {
let ref: HTMLLabelElement | null = null;
render(
createElement(
Label,
{
ref: (el: HTMLLabelElement | null) => {
ref = el;
},
},
"Label",
),
);
expect(ref).toBeInstanceOf(HTMLLabelElement);
expect(ref?.tagName).toBe("LABEL");
});
});
describe("Base Classes", () => {
it("always includes base classes", () => {
render(
createElement(
Label,
{
"data-testid": "label",
},
"Label",
),
);
const label = screen.getByTestId("label");
expect(label.className).toContain("font-medium");
expect(label.className).toContain("text-foreground");
expect(label.className).toContain("cursor-pointer");
});
it("includes peer-disabled classes", () => {
render(
createElement(
Label,
{
"data-testid": "label",
},
"Label",
),
);
const label = screen.getByTestId("label");
expect(label.className).toContain("peer-disabled:cursor-not-allowed");
expect(label.className).toContain("peer-disabled:opacity-50");
});
});
describe("Compound Scenarios", () => {
it("works correctly with required and small size", () => {
render(
createElement(
Label,
{
required: true,
size: "sm",
"data-testid": "label",
},
"Small required",
),
);
const label = screen.getByTestId("label");
expect(label.className).toContain("text-sm");
expect(screen.getByText("*")).toBeInTheDocument();
});
it("works correctly with disabled and required", () => {
render(
createElement(
Label,
{
disabled: true,
required: true,
"data-testid": "label",
},
"Disabled required",
),
);
const label = screen.getByTestId("label");
expect(label.className).toContain("opacity-50");
expect(label.className).toContain("cursor-not-allowed");
expect(screen.getByText("*")).toBeInTheDocument();
});
it("works correctly with htmlFor and required", () => {
render(
createElement(
Label,
{
htmlFor: "field-id",
required: true,
"data-testid": "label",
},
"Field label",
),
);
const label = screen.getByTestId("label");
expect(label).toHaveAttribute("for", "field-id");
expect(screen.getByText("*")).toBeInTheDocument();
});
it("works correctly with large size and custom className", () => {
render(
createElement(
Label,
{
size: "lg",
className: "font-bold",
"data-testid": "label",
},
"Large bold",
),
);
const label = screen.getByTestId("label");
expect(label.className).toContain("text-lg");
expect(label.className).toContain("font-bold");
});
});
describe("Integration with Form Controls", () => {
it("associates correctly with input element", () => {
const { container } = render(
createElement(
"div",
null,
createElement(Label, { htmlFor: "test-input" }, "Test Label"),
createElement("input", { id: "test-input", type: "text" }),
),
);
const label = container.querySelector("label");
const input = container.querySelector("input");
expect(label).toHaveAttribute("for", "test-input");
expect(input).toHaveAttribute("id", "test-input");
});
it("clicking label focuses associated input", () => {
const { container } = render(
createElement(
"div",
null,
createElement(
Label,
{ htmlFor: "focus-input", "data-testid": "label" },
"Click me",
),
createElement("input", {
id: "focus-input",
type: "text",
"data-testid": "input",
}),
),
);
const label = screen.getByTestId("label");
const input = screen.getByTestId("input") as HTMLInputElement;
// Verify the association exists
expect(label).toHaveAttribute("for", "focus-input");
expect(input).toHaveAttribute("id", "focus-input");
// Focus the input directly to verify it can receive focus
input.focus();
expect(input).toHaveFocus();
});
});
});

View file

@ -0,0 +1,65 @@
import { cn } from "@tpmjs/utils/cn";
import { createElement, forwardRef } from "react";
import type { LabelProps } from "./types";
import { labelVariants } from "./variants";
/**
* Label component
*
* A label component for form inputs with proper accessibility.
* Built with .ts-only React using createElement.
*
* @example
* ```typescript
* import { Label } from '@tpmjs/ui/Label/Label';
* import { createElement } from 'react';
*
* function MyComponent() {
* return createElement(Label, {
* htmlFor: 'email',
* children: 'Email Address',
* });
* }
* ```
*/
export const Label = forwardRef<HTMLLabelElement, LabelProps>(
(
{
className,
size = "md",
required = false,
disabled = false,
children,
...props
},
ref,
) => {
return createElement(
"label",
{
className: cn(
labelVariants({
size,
disabled: disabled ? "true" : "false",
}),
className,
),
ref,
...props,
},
children,
required
? createElement(
"span",
{
className: "ml-1 text-error",
"aria-hidden": "true",
},
"*",
)
: null,
);
},
);
Label.displayName = "Label";

View file

@ -0,0 +1,27 @@
import { spacing } from "../tokens";
/**
* Label component design tokens
* Maps global tokens to label-specific semantics
*/
export const labelTokens = {
/** Font sizes for each size */
fontSize: {
sm: "0.875rem", // 14px
md: "1rem", // 16px
lg: "1.125rem", // 18px
},
/** Line heights for each size */
lineHeight: {
sm: "1.25rem", // 20px
md: "1.5rem", // 24px
lg: "1.75rem", // 28px
},
/** Margin bottom (spacing from label to input) */
marginBottom: spacing[2], // 0.5rem (8px)
/** Required indicator spacing */
requiredSpacing: spacing[1], // 0.25rem (4px)
} as const;

View file

@ -0,0 +1,29 @@
import type { LabelHTMLAttributes } from "react";
/**
* Label component props
*/
export interface LabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
/**
* Size of the label
* @default 'md'
*/
size?: "sm" | "md" | "lg";
/**
* Whether the associated field is required
* @default false
*/
required?: boolean;
/**
* Whether the label should be visually disabled
* @default false
*/
disabled?: boolean;
}
/**
* Label ref type
*/
export type LabelRef = HTMLLabelElement;

View file

@ -0,0 +1,37 @@
import { createVariants } from "../system/variants";
/**
* Label variant definitions
* Uses custom variant system for type-safe class composition
*/
export const labelVariants = createVariants({
base: [
// Typography
"font-medium text-foreground",
"leading-none",
// Cursor
"cursor-pointer",
// Peer styling support
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
].join(" "),
variants: {
size: {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
},
disabled: {
true: "cursor-not-allowed opacity-50",
false: "",
},
},
compoundVariants: [],
defaultVariants: {
size: "md",
disabled: "false",
},
});

View file

@ -0,0 +1,359 @@
import { render, screen } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it } from "vitest";
import { ProgressBar } from "./ProgressBar";
describe("ProgressBar", () => {
describe("Rendering", () => {
it("renders a progress bar element", () => {
render(
createElement(ProgressBar, { value: 50, "data-testid": "progress" }),
);
const progress = screen.getByTestId("progress");
expect(progress).toBeInTheDocument();
expect(progress.tagName).toBe("DIV");
});
it("has progressbar role", () => {
render(
createElement(ProgressBar, { value: 50, "data-testid": "progress" }),
);
const progress = screen.getByTestId("progress");
expect(progress).toHaveAttribute("role", "progressbar");
});
it("sets correct aria attributes", () => {
render(
createElement(ProgressBar, { value: 75, "data-testid": "progress" }),
);
const progress = screen.getByTestId("progress");
expect(progress).toHaveAttribute("aria-valuenow", "75");
expect(progress).toHaveAttribute("aria-valuemin", "0");
expect(progress).toHaveAttribute("aria-valuemax", "100");
});
it("renders fill element with correct width", () => {
render(
createElement(ProgressBar, { value: 60, "data-testid": "progress" }),
);
const progress = screen.getByTestId("progress");
const fill = progress.querySelector("div");
expect(fill).toHaveStyle({ width: "60%" });
});
});
describe("Value Handling", () => {
it("clamps value above 100 to 100", () => {
render(
createElement(ProgressBar, { value: 150, "data-testid": "progress" }),
);
const progress = screen.getByTestId("progress");
expect(progress).toHaveAttribute("aria-valuenow", "100");
const fill = progress.querySelector("div");
expect(fill).toHaveStyle({ width: "100%" });
});
it("clamps value below 0 to 0", () => {
render(
createElement(ProgressBar, { value: -10, "data-testid": "progress" }),
);
const progress = screen.getByTestId("progress");
expect(progress).toHaveAttribute("aria-valuenow", "0");
const fill = progress.querySelector("div");
expect(fill).toHaveStyle({ width: "0%" });
});
it("handles 0 value", () => {
render(
createElement(ProgressBar, { value: 0, "data-testid": "progress" }),
);
const progress = screen.getByTestId("progress");
expect(progress).toHaveAttribute("aria-valuenow", "0");
const fill = progress.querySelector("div");
expect(fill).toHaveStyle({ width: "0%" });
});
it("handles 100 value", () => {
render(
createElement(ProgressBar, { value: 100, "data-testid": "progress" }),
);
const progress = screen.getByTestId("progress");
expect(progress).toHaveAttribute("aria-valuenow", "100");
const fill = progress.querySelector("div");
expect(fill).toHaveStyle({ width: "100%" });
});
it("handles decimal values", () => {
render(
createElement(ProgressBar, { value: 33.7, "data-testid": "progress" }),
);
const progress = screen.getByTestId("progress");
expect(progress).toHaveAttribute("aria-valuenow", "33.7");
const fill = progress.querySelector("div");
expect(fill).toHaveStyle({ width: "33.7%" });
});
});
describe("Size Variants", () => {
it("applies small size", () => {
render(
createElement(ProgressBar, {
value: 50,
size: "sm",
"data-testid": "progress",
}),
);
const progress = screen.getByTestId("progress");
expect(progress.className).toContain("h-1");
});
it("applies medium size", () => {
render(
createElement(ProgressBar, {
value: 50,
size: "md",
"data-testid": "progress",
}),
);
const progress = screen.getByTestId("progress");
expect(progress.className).toContain("h-2");
});
it("applies large size", () => {
render(
createElement(ProgressBar, {
value: 50,
size: "lg",
"data-testid": "progress",
}),
);
const progress = screen.getByTestId("progress");
expect(progress.className).toContain("h-3");
});
it("uses md size by default", () => {
render(
createElement(ProgressBar, { value: 50, "data-testid": "progress" }),
);
const progress = screen.getByTestId("progress");
expect(progress.className).toContain("h-2");
});
});
describe("Color Variants", () => {
it("applies primary variant", () => {
render(
createElement(ProgressBar, {
value: 50,
variant: "primary",
"data-testid": "progress",
}),
);
const progress = screen.getByTestId("progress");
const fill = progress.querySelector("div");
expect(fill?.className).toContain("bg-blue-500");
});
it("applies success variant", () => {
render(
createElement(ProgressBar, {
value: 50,
variant: "success",
"data-testid": "progress",
}),
);
const progress = screen.getByTestId("progress");
const fill = progress.querySelector("div");
expect(fill?.className).toContain("bg-green-500");
});
it("applies warning variant", () => {
render(
createElement(ProgressBar, {
value: 50,
variant: "warning",
"data-testid": "progress",
}),
);
const progress = screen.getByTestId("progress");
const fill = progress.querySelector("div");
expect(fill?.className).toContain("bg-yellow-500");
});
it("applies danger variant", () => {
render(
createElement(ProgressBar, {
value: 50,
variant: "danger",
"data-testid": "progress",
}),
);
const progress = screen.getByTestId("progress");
const fill = progress.querySelector("div");
expect(fill?.className).toContain("bg-red-500");
});
it("uses primary variant by default", () => {
render(
createElement(ProgressBar, { value: 50, "data-testid": "progress" }),
);
const progress = screen.getByTestId("progress");
const fill = progress.querySelector("div");
expect(fill?.className).toContain("bg-blue-500");
});
});
describe("Label Display", () => {
it("does not show label by default", () => {
const { container } = render(createElement(ProgressBar, { value: 50 }));
expect(container.textContent).toBe("");
});
it("shows label when showLabel is true", () => {
render(createElement(ProgressBar, { value: 50, showLabel: true }));
expect(screen.getByText("50%")).toBeInTheDocument();
});
it("rounds label to nearest integer", () => {
render(createElement(ProgressBar, { value: 33.7, showLabel: true }));
expect(screen.getByText("34%")).toBeInTheDocument();
});
it("shows 0% label for 0 value", () => {
render(createElement(ProgressBar, { value: 0, showLabel: true }));
expect(screen.getByText("0%")).toBeInTheDocument();
});
it("shows 100% label for 100 value", () => {
render(createElement(ProgressBar, { value: 100, showLabel: true }));
expect(screen.getByText("100%")).toBeInTheDocument();
});
it("label has correct styling classes", () => {
render(createElement(ProgressBar, { value: 50, showLabel: true }));
const label = screen.getByText("50%");
expect(label.className).toContain("text-sm");
expect(label.className).toContain("text-zinc-400");
expect(label.className).toContain("tabular-nums");
});
});
describe("HTML Attributes", () => {
it("passes through id attribute", () => {
render(
createElement(ProgressBar, {
value: 50,
id: "progress-id",
"data-testid": "progress",
}),
);
const progress = screen.getByTestId("progress");
expect(progress).toHaveAttribute("id", "progress-id");
});
it("passes through data attributes", () => {
render(
createElement(ProgressBar, {
value: 50,
"data-custom": "test",
"data-testid": "progress",
}),
);
const progress = screen.getByTestId("progress");
expect(progress).toHaveAttribute("data-custom", "test");
});
});
describe("Custom className", () => {
it("merges custom className with variant classes", () => {
render(
createElement(ProgressBar, {
value: 50,
className: "custom-class",
"data-testid": "progress",
}),
);
const progress = screen.getByTestId("progress");
expect(progress.className).toContain("custom-class");
expect(progress.className).toContain("bg-zinc-800");
expect(progress.className).toContain("rounded");
});
});
describe("Ref Forwarding", () => {
it("forwards ref to progress bar element", () => {
let ref: HTMLDivElement | null = null;
render(
createElement(ProgressBar, {
value: 50,
ref: (el: HTMLDivElement | null) => {
ref = el;
},
}),
);
expect(ref).toBeInstanceOf(HTMLDivElement);
expect(ref).toHaveAttribute("role", "progressbar");
});
});
describe("Base Classes", () => {
it("always includes track base classes", () => {
render(
createElement(ProgressBar, { value: 50, "data-testid": "progress" }),
);
const progress = screen.getByTestId("progress");
expect(progress.className).toContain("relative");
expect(progress.className).toContain("overflow-hidden");
expect(progress.className).toContain("bg-zinc-800");
expect(progress.className).toContain("rounded");
});
it("fill includes base classes", () => {
render(
createElement(ProgressBar, { value: 50, "data-testid": "progress" }),
);
const progress = screen.getByTestId("progress");
const fill = progress.querySelector("div");
expect(fill?.className).toContain("h-full");
expect(fill?.className).toContain("transition-all");
expect(fill?.className).toContain("rounded");
});
});
describe("Compound Scenarios", () => {
it("works correctly with large size and success variant", () => {
render(
createElement(ProgressBar, {
value: 80,
size: "lg",
variant: "success",
"data-testid": "progress",
}),
);
const progress = screen.getByTestId("progress");
expect(progress.className).toContain("h-3");
const fill = progress.querySelector("div");
expect(fill?.className).toContain("bg-green-500");
expect(fill).toHaveStyle({ width: "80%" });
});
it("works correctly with label, custom className, and warning variant", () => {
render(
createElement(ProgressBar, {
value: 45,
variant: "warning",
showLabel: true,
className: "my-custom-class",
"data-testid": "progress",
}),
);
const wrapper = screen.getByTestId("progress");
// Custom className is applied to the track, which is the first child
const track = wrapper.querySelector('[role="progressbar"]');
expect(track?.className).toContain("my-custom-class");
const fill = track?.querySelector("div");
expect(fill?.className).toContain("bg-yellow-500");
expect(screen.getByText("45%")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,116 @@
import { cn } from "@tpmjs/utils/cn";
import { createElement, forwardRef } from "react";
import type { ProgressBarProps } from "./types";
import { progressBarFillVariants, progressBarTrackVariants } from "./variants";
/**
* ProgressBar component
*
* Displays progress as a horizontal bar with optional label.
* Supports multiple size and color variants.
* Built with .ts-only React using createElement.
*
* @example
* ```typescript
* import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
* import { createElement } from 'react';
*
* function MyComponent() {
* return createElement(ProgressBar, {
* value: 75,
* variant: 'success',
* size: 'md',
* showLabel: true,
* });
* }
* ```
*/
export const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(
(
{
className,
value,
size = "md",
variant = "primary",
showLabel = false,
...props
},
ref,
) => {
// Clamp value between 0 and 100
const clampedValue = Math.min(Math.max(value, 0), 100);
if (!showLabel) {
return createElement(
"div",
{
className: cn(
progressBarTrackVariants({
size,
}),
className,
),
role: "progressbar",
"aria-valuenow": clampedValue,
"aria-valuemin": 0,
"aria-valuemax": 100,
ref,
...props,
},
createElement("div", {
className: progressBarFillVariants({
variant,
}),
style: {
width: `${clampedValue}%`,
},
}),
);
}
return createElement(
"div",
{
className: "flex items-center gap-3",
ref,
...props,
},
[
createElement(
"div",
{
key: "track",
className: cn(
progressBarTrackVariants({
size,
}),
className,
),
role: "progressbar",
"aria-valuenow": clampedValue,
"aria-valuemin": 0,
"aria-valuemax": 100,
},
createElement("div", {
className: progressBarFillVariants({
variant,
}),
style: {
width: `${clampedValue}%`,
},
}),
),
createElement(
"span",
{
key: "label",
className: "text-sm text-zinc-400 tabular-nums min-w-[3ch]",
},
`${Math.round(clampedValue)}%`,
),
],
);
},
);
ProgressBar.displayName = "ProgressBar";

View file

@ -0,0 +1,17 @@
import { spacing } from "../tokens";
/**
* ProgressBar component design tokens
* Maps global tokens to progress bar-specific semantics
*/
export const progressBarTokens = {
/** Height for each size */
height: {
sm: spacing[1], // 0.25rem (4px)
md: spacing[2], // 0.5rem (8px)
lg: spacing[3], // 0.75rem (12px)
},
/** Border radius */
radius: spacing[1], // 0.25rem (4px)
} as const;

View file

@ -0,0 +1,35 @@
import type { HTMLAttributes } from "react";
/**
* ProgressBar component props
*/
export interface ProgressBarProps
extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
/**
* Progress value (0-100)
*/
value: number;
/**
* Size variant
* @default 'md'
*/
size?: "sm" | "md" | "lg";
/**
* Color variant
* @default 'primary'
*/
variant?: "primary" | "success" | "warning" | "danger";
/**
* Whether to show percentage label
* @default false
*/
showLabel?: boolean;
}
/**
* ProgressBar ref type
*/
export type ProgressBarRef = HTMLDivElement;

View file

@ -0,0 +1,58 @@
import { createVariants } from "../system/variants";
/**
* ProgressBar track variant definitions
*/
export const progressBarTrackVariants = createVariants({
base: [
// Layout
"relative overflow-hidden",
// Background
"bg-zinc-800",
// Border
"rounded",
].join(" "),
variants: {
size: {
sm: "h-1", // 4px
md: "h-2", // 8px
lg: "h-3", // 12px
},
},
compoundVariants: [],
defaultVariants: {
size: "md",
},
});
/**
* ProgressBar fill variant definitions
*/
export const progressBarFillVariants = createVariants({
base: [
// Layout
"h-full",
// Transition
"transition-all duration-300 ease-out",
// Border
"rounded",
].join(" "),
variants: {
variant: {
primary: "bg-blue-500",
success: "bg-green-500",
warning: "bg-yellow-500",
danger: "bg-red-500",
},
},
compoundVariants: [],
defaultVariants: {
variant: "primary",
},
});

View file

@ -0,0 +1,537 @@
import { render, screen } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it, vi } from "vitest";
import { Tabs } from "./Tabs";
import type { Tab } from "./types";
const mockTabs: Tab[] = [
{ id: "all", label: "All Tools", count: 1234 },
{ id: "featured", label: "Featured", count: 42 },
{ id: "recent", label: "Recent" },
];
describe("Tabs", () => {
describe("Rendering", () => {
it("renders tabs container", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
"data-testid": "tabs",
}),
);
const tabs = screen.getByTestId("tabs");
expect(tabs).toBeInTheDocument();
expect(tabs.tagName).toBe("DIV");
});
it("has tablist role", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
"data-testid": "tabs",
}),
);
const tabs = screen.getByTestId("tabs");
expect(tabs).toHaveAttribute("role", "tablist");
});
it("renders all tabs", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
expect(screen.getByTestId("tab-all")).toBeInTheDocument();
expect(screen.getByTestId("tab-featured")).toBeInTheDocument();
expect(screen.getByTestId("tab-recent")).toBeInTheDocument();
});
it("renders tab labels", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
expect(screen.getByText("All Tools")).toBeInTheDocument();
expect(screen.getByText("Featured")).toBeInTheDocument();
expect(screen.getByText("Recent")).toBeInTheDocument();
});
it("renders tabs as buttons", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
const tab = screen.getByTestId("tab-all");
expect(tab.tagName).toBe("BUTTON");
expect(tab).toHaveAttribute("type", "button");
});
});
describe("Active State", () => {
it("marks active tab with aria-selected", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "featured",
onTabChange: handleChange,
}),
);
expect(screen.getByTestId("tab-featured")).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByTestId("tab-all")).toHaveAttribute(
"aria-selected",
"false",
);
expect(screen.getByTestId("tab-recent")).toHaveAttribute(
"aria-selected",
"false",
);
});
it("applies active styling to active tab", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
const activeTab = screen.getByTestId("tab-all");
expect(activeTab.className).toContain("text-zinc-100");
expect(activeTab.className).toContain("border-blue-500");
});
it("applies inactive styling to inactive tabs", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
const inactiveTab = screen.getByTestId("tab-featured");
expect(inactiveTab.className).toContain("text-zinc-400");
expect(inactiveTab.className).toContain("border-transparent");
});
});
describe("Click Handling", () => {
it("calls onTabChange when tab is clicked", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
screen.getByTestId("tab-featured").click();
expect(handleChange).toHaveBeenCalledWith("featured");
});
it("calls onTabChange with correct tab ID", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
screen.getByTestId("tab-recent").click();
expect(handleChange).toHaveBeenCalledWith("recent");
expect(handleChange).toHaveBeenCalledTimes(1);
});
it("allows clicking already active tab", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
screen.getByTestId("tab-all").click();
expect(handleChange).toHaveBeenCalledWith("all");
});
});
describe("Count Badges", () => {
it("renders count badge when count is provided", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
expect(screen.getByText("1234")).toBeInTheDocument();
expect(screen.getByText("42")).toBeInTheDocument();
});
it("does not render count badge when count is undefined", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
const recentTab = screen.getByTestId("tab-recent");
const badge = recentTab.querySelector("span[aria-label]");
expect(badge).not.toBeInTheDocument();
});
it("count badge has aria-label", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
const badge = screen.getByText("1234");
expect(badge).toHaveAttribute("aria-label", "1234 items");
});
it("applies active styling to count badge on active tab", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
const activeBadge = screen.getByText("1234");
expect(activeBadge.className).toContain("text-zinc-100");
});
it("applies inactive styling to count badge on inactive tab", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
const inactiveBadge = screen.getByText("42");
expect(inactiveBadge.className).toContain("text-zinc-500");
});
it("handles zero count", () => {
const handleChange = vi.fn();
const tabsWithZero: Tab[] = [{ id: "empty", label: "Empty", count: 0 }];
render(
createElement(Tabs, {
tabs: tabsWithZero,
activeTab: "empty",
onTabChange: handleChange,
}),
);
expect(screen.getByText("0")).toBeInTheDocument();
});
});
describe("Size Variants", () => {
it("applies small size", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
size: "sm",
}),
);
const tab = screen.getByTestId("tab-all");
expect(tab.className).toContain("text-sm");
expect(tab.className).toContain("px-3");
expect(tab.className).toContain("py-2");
});
it("applies medium size", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
size: "md",
}),
);
const tab = screen.getByTestId("tab-all");
expect(tab.className).toContain("text-base");
expect(tab.className).toContain("px-4");
expect(tab.className).toContain("py-3");
});
it("applies large size", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
size: "lg",
}),
);
const tab = screen.getByTestId("tab-all");
expect(tab.className).toContain("text-lg");
expect(tab.className).toContain("px-6");
expect(tab.className).toContain("py-4");
});
it("uses md size by default", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
const tab = screen.getByTestId("tab-all");
expect(tab.className).toContain("text-base");
expect(tab.className).toContain("px-4");
expect(tab.className).toContain("py-3");
});
});
describe("Accessibility", () => {
it('tabs have role="tab"', () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
const tab = screen.getByTestId("tab-all");
expect(tab).toHaveAttribute("role", "tab");
});
it("tabs have unique IDs", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
expect(screen.getByTestId("tab-all")).toHaveAttribute("id", "tab-all");
expect(screen.getByTestId("tab-featured")).toHaveAttribute(
"id",
"tab-featured",
);
expect(screen.getByTestId("tab-recent")).toHaveAttribute(
"id",
"tab-recent",
);
});
it("tabs have aria-controls attribute", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
expect(screen.getByTestId("tab-all")).toHaveAttribute(
"aria-controls",
"tabpanel-all",
);
expect(screen.getByTestId("tab-featured")).toHaveAttribute(
"aria-controls",
"tabpanel-featured",
);
});
});
describe("HTML Attributes", () => {
it("passes through id attribute", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
id: "tabs-id",
"data-testid": "tabs",
}),
);
const tabs = screen.getByTestId("tabs");
expect(tabs).toHaveAttribute("id", "tabs-id");
});
it("passes through data attributes", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
"data-custom": "test",
"data-testid": "tabs",
}),
);
const tabs = screen.getByTestId("tabs");
expect(tabs).toHaveAttribute("data-custom", "test");
});
});
describe("Custom className", () => {
it("merges custom className with variant classes", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
className: "custom-class",
"data-testid": "tabs",
}),
);
const tabs = screen.getByTestId("tabs");
expect(tabs.className).toContain("custom-class");
expect(tabs.className).toContain("flex");
expect(tabs.className).toContain("border-b");
});
});
describe("Ref Forwarding", () => {
it("forwards ref to container element", () => {
const handleChange = vi.fn();
let ref: HTMLDivElement | null = null;
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
ref: (el: HTMLDivElement | null) => {
ref = el;
},
}),
);
expect(ref).toBeInstanceOf(HTMLDivElement);
expect(ref).toHaveAttribute("role", "tablist");
});
});
describe("Base Classes", () => {
it("container includes base classes", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
"data-testid": "tabs",
}),
);
const tabs = screen.getByTestId("tabs");
expect(tabs.className).toContain("flex");
expect(tabs.className).toContain("items-center");
expect(tabs.className).toContain("overflow-x-auto");
expect(tabs.className).toContain("border-b");
});
it("tab buttons include base classes", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "all",
onTabChange: handleChange,
}),
);
const tab = screen.getByTestId("tab-all");
expect(tab.className).toContain("inline-flex");
expect(tab.className).toContain("items-center");
expect(tab.className).toContain("font-medium");
expect(tab.className).toContain("whitespace-nowrap");
expect(tab.className).toContain("border-b-2");
expect(tab.className).toContain("transition-colors");
});
});
describe("Compound Scenarios", () => {
it("works correctly with large size and active tab with count", () => {
const handleChange = vi.fn();
render(
createElement(Tabs, {
tabs: mockTabs,
activeTab: "featured",
onTabChange: handleChange,
size: "lg",
}),
);
const tab = screen.getByTestId("tab-featured");
expect(tab.className).toContain("text-lg");
expect(tab.className).toContain("px-6");
expect(tab.className).toContain("py-4");
expect(tab.className).toContain("text-zinc-100");
expect(tab.className).toContain("border-blue-500");
const badge = screen.getByText("42");
expect(badge.className).toContain("text-zinc-100");
});
it("handles single tab", () => {
const handleChange = vi.fn();
const singleTab: Tab[] = [{ id: "only", label: "Only Tab" }];
render(
createElement(Tabs, {
tabs: singleTab,
activeTab: "only",
onTabChange: handleChange,
}),
);
expect(screen.getByTestId("tab-only")).toBeInTheDocument();
expect(screen.getByText("Only Tab")).toBeInTheDocument();
});
it("handles empty tabs array", () => {
const handleChange = vi.fn();
const { container } = render(
createElement(Tabs, {
tabs: [],
activeTab: "",
onTabChange: handleChange,
}),
);
const buttons = container.querySelectorAll("button");
expect(buttons.length).toBe(0);
});
});
});

View file

@ -0,0 +1,98 @@
import { cn } from "@tpmjs/utils/cn";
import { createElement, forwardRef } from "react";
import type { TabsProps } from "./types";
import {
tabButtonVariants,
tabCountVariants,
tabsContainerVariants,
} from "./variants";
/**
* Tabs component
*
* Displays a horizontal list of tabs with optional count badges.
* Supports keyboard navigation and accessibility.
* Built with .ts-only React using createElement.
*
* @example
* ```typescript
* import { Tabs } from '@tpmjs/ui/Tabs/Tabs';
* import { createElement, useState } from 'react';
*
* function MyComponent() {
* const [activeTab, setActiveTab] = useState('all');
*
* return createElement(Tabs, {
* tabs: [
* { id: 'all', label: 'All Tools', count: 1234 },
* { id: 'featured', label: 'Featured', count: 42 },
* ],
* activeTab,
* onTabChange: setActiveTab,
* size: 'md',
* });
* }
* ```
*/
export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
({ className, tabs, activeTab, onTabChange, size = "md", ...props }, ref) => {
return createElement(
"div",
{
className: cn(
tabsContainerVariants({
size,
}),
className,
),
role: "tablist",
ref,
...props,
},
tabs.map((tab) => {
const isActive = tab.id === activeTab;
return createElement(
"button",
{
key: tab.id,
type: "button",
role: "tab",
"aria-selected": isActive,
"aria-controls": `tabpanel-${tab.id}`,
id: `tab-${tab.id}`,
className: tabButtonVariants({
size,
active: isActive ? "true" : "false",
}),
onClick: () => onTabChange(tab.id),
"data-testid": `tab-${tab.id}`,
},
[
createElement(
"span",
{
key: "label",
},
tab.label,
),
tab.count !== undefined &&
createElement(
"span",
{
key: "count",
className: tabCountVariants({
active: isActive ? "true" : "false",
}),
"aria-label": `${tab.count} items`,
},
tab.count.toString(),
),
].filter(Boolean),
);
}),
);
},
);
Tabs.displayName = "Tabs";

View file

@ -0,0 +1,33 @@
import { fontSize, spacing } from "../tokens";
/**
* Tabs component design tokens
* Maps global tokens to tabs-specific semantics
*/
export const tabsTokens = {
/** Padding for each size */
padding: {
sm: {
x: spacing[3], // 0.75rem (12px)
y: spacing[2], // 0.5rem (8px)
},
md: {
x: spacing[4], // 1rem (16px)
y: spacing[3], // 0.75rem (12px)
},
lg: {
x: spacing[6], // 1.5rem (24px)
y: spacing[4], // 1rem (16px)
},
},
/** Font size for each size */
fontSize: {
sm: fontSize.sm, // 0.875rem (14px)
md: fontSize.base, // 1rem (16px)
lg: fontSize.lg, // 1.125rem (18px)
},
/** Gap between tabs */
gap: spacing[1], // 0.25rem (4px)
} as const;

View file

@ -0,0 +1,53 @@
import type { HTMLAttributes } from "react";
/**
* Tab definition
*/
export interface Tab {
/**
* Unique identifier for the tab
*/
id: string;
/**
* Display label for the tab
*/
label: string;
/**
* Optional badge count to display
*/
count?: number;
}
/**
* Tabs component props
*/
export interface TabsProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
/**
* Array of tabs to display
*/
tabs: Tab[];
/**
* Currently active tab ID
*/
activeTab: string;
/**
* Callback when tab is changed
*/
onTabChange: (tabId: string) => void;
/**
* Size variant
* @default 'md'
*/
size?: "sm" | "md" | "lg";
}
/**
* Tabs ref type
*/
export type TabsRef = HTMLDivElement;

View file

@ -0,0 +1,100 @@
import { createVariants } from "../system/variants";
/**
* Tabs container variant definitions
*/
export const tabsContainerVariants = createVariants({
base: [
// Layout
"flex items-center",
// Overflow
"overflow-x-auto",
// Border
"border-b border-zinc-800",
].join(" "),
variants: {
size: {
sm: "gap-1",
md: "gap-1",
lg: "gap-1",
},
},
compoundVariants: [],
defaultVariants: {
size: "md",
},
});
/**
* Tab button variant definitions
*/
export const tabButtonVariants = createVariants({
base: [
// Display
"inline-flex items-center gap-2",
// Font
"font-medium whitespace-nowrap",
// Border
"border-b-2",
// Transition
"transition-colors duration-200",
// Cursor
"cursor-pointer",
].join(" "),
variants: {
size: {
sm: "text-sm px-3 py-2",
md: "text-base px-4 py-3",
lg: "text-lg px-6 py-4",
},
active: {
true: "text-zinc-100 border-blue-500",
false:
"text-zinc-400 border-transparent hover:text-zinc-300 hover:border-zinc-700",
},
},
compoundVariants: [],
defaultVariants: {
size: "md",
active: "false",
},
});
/**
* Tab count badge variant definitions
*/
export const tabCountVariants = createVariants({
base: [
// Display
"inline-flex items-center justify-center",
// Size
"min-w-[1.25rem] h-5",
// Padding
"px-1.5",
// Font
"text-xs font-medium tabular-nums",
// Background
"bg-zinc-800",
// Border
"rounded-full",
].join(" "),
variants: {
active: {
true: "text-zinc-100",
false: "text-zinc-500",
},
},
compoundVariants: [],
defaultVariants: {
active: "false",
},
});

View file

@ -0,0 +1,111 @@
/**
* Custom variant system for TPMJS UI components
* Lightweight, type-safe alternative to class-variance-authority (CVA)
*
* @example
* ```typescript
* const buttonVariants = createVariants({
* base: 'inline-flex items-center',
* variants: {
* variant: {
* default: 'bg-primary text-primary-foreground',
* outline: 'border border-border',
* },
* size: {
* sm: 'h-9 px-3',
* md: 'h-10 px-4',
* },
* },
* compoundVariants: [
* {
* conditions: { variant: 'outline', size: 'sm' },
* className: 'border-2',
* },
* ],
* defaultVariants: {
* variant: 'default',
* size: 'md',
* },
* });
*
* // Usage
* buttonVariants({ variant: 'outline', size: 'sm' });
* // Returns: "inline-flex items-center border border-border h-9 px-3 border-2"
* ```
*/
/**
* Configuration for a variant system
*/
export type VariantConfig<T extends Record<string, Record<string, string>>> = {
/** Base classes applied to all variants */
base: string;
/** Variant definitions - each key is a variant axis with possible values */
variants: T;
/** Compound variants - apply classes when multiple conditions are met */
compoundVariants?: Array<{
conditions: Partial<{ [K in keyof T]: keyof T[K] }>;
className: string;
}>;
/** Default variant values */
defaultVariants?: Partial<{ [K in keyof T]: keyof T[K] }>;
};
/**
* Props type for consuming variant functions
*/
export type VariantProps<T extends Record<string, Record<string, string>>> =
Partial<{
[K in keyof T]: keyof T[K];
}>;
/**
* Creates a type-safe variant composition function
*
* @param config - Variant configuration with base, variants, compound variants, and defaults
* @returns Function that accepts variant props and returns composed className string
*/
export function createVariants<
T extends Record<string, Record<string, string>>,
>(config: VariantConfig<T>) {
return function getVariantClasses(props?: VariantProps<T>): string {
const classes: string[] = [config.base];
// Merge props with defaults
const mergedProps = {
...config.defaultVariants,
...props,
} as VariantProps<T>;
// Apply variant classes
for (const [key, value] of Object.entries(mergedProps)) {
const valueStr = value as string;
if (
value !== undefined &&
value !== null &&
config.variants[key]?.[valueStr]
) {
classes.push(config.variants[key][valueStr]);
}
}
// Apply compound variants
if (config.compoundVariants) {
for (const { conditions, className } of config.compoundVariants) {
const matches = Object.entries(conditions).every(
([key, value]) =>
mergedProps[key as keyof typeof mergedProps] === value,
);
if (matches) {
classes.push(className);
}
}
}
// Filter out any empty strings and join
return classes.filter(Boolean).join(" ");
};
}

View file

@ -0,0 +1,129 @@
/**
* Animation token system for TPMJS UI
* Smooth, subtle transitions for polished micro-interactions
*/
/**
* Animation durations
*/
export const duration = {
fast: "150ms",
base: "200ms",
slow: "300ms",
slower: "500ms",
slowest: "700ms",
} as const;
/**
* Easing functions
* Cubic bezier curves for natural motion
*/
export const easing = {
linear: "linear",
ease: "ease",
easeIn: "cubic-bezier(0.4, 0, 1, 1)",
easeOut: "cubic-bezier(0, 0, 0.2, 1)",
easeInOut: "cubic-bezier(0.4, 0, 0.2, 1)",
// Custom easings for specific interactions
smooth: "cubic-bezier(0.25, 0.1, 0.25, 1)",
bounce: "cubic-bezier(0.68, -0.55, 0.265, 1.55)",
spring: "cubic-bezier(0.175, 0.885, 0.32, 1.275)",
} as const;
/**
* Keyframe animations
* Predefined animation sequences
*/
export const keyframes = {
fadeIn: {
from: { opacity: "0" },
to: { opacity: "1" },
},
fadeOut: {
from: { opacity: "1" },
to: { opacity: "0" },
},
slideUp: {
from: { transform: "translateY(10px)", opacity: "0" },
to: { transform: "translateY(0)", opacity: "1" },
},
slideDown: {
from: { transform: "translateY(-10px)", opacity: "0" },
to: { transform: "translateY(0)", opacity: "1" },
},
slideLeft: {
from: { transform: "translateX(10px)", opacity: "0" },
to: { transform: "translateX(0)", opacity: "1" },
},
slideRight: {
from: { transform: "translateX(-10px)", opacity: "0" },
to: { transform: "translateX(0)", opacity: "1" },
},
scaleIn: {
from: { transform: "scale(0.95)", opacity: "0" },
to: { transform: "scale(1)", opacity: "1" },
},
scaleOut: {
from: { transform: "scale(1)", opacity: "1" },
to: { transform: "scale(0.95)", opacity: "0" },
},
shimmer: {
"0%": { backgroundPosition: "-1000px 0" },
"100%": { backgroundPosition: "1000px 0" },
},
pulse: {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.5" },
},
spin: {
from: { transform: "rotate(0deg)" },
to: { transform: "rotate(360deg)" },
},
ping: {
"75%, 100%": { transform: "scale(2)", opacity: "0" },
},
bounce: {
"0%, 100%": {
transform: "translateY(-25%)",
animationTimingFunction: "cubic-bezier(0.8, 0, 1, 1)",
},
"50%": {
transform: "translateY(0)",
animationTimingFunction: "cubic-bezier(0, 0, 0.2, 1)",
},
},
} as const;
/**
* Transition presets
* Commonly used transition combinations
*/
export const transitions = {
/** Base transition for colors and backgrounds */
base: `background-color ${duration.base} ${easing.easeInOut}, border-color ${duration.base} ${easing.easeInOut}, color ${duration.base} ${easing.easeInOut}`,
/** Transform transition */
transform: `transform ${duration.base} ${easing.easeInOut}`,
/** Opacity transition */
opacity: `opacity ${duration.base} ${easing.easeInOut}`,
/** All properties transition */
all: `all ${duration.slow} ${easing.easeInOut}`,
/** Fast transition for immediate feedback */
fast: `all ${duration.fast} ${easing.easeOut}`,
/** Smooth transition for hover effects */
smooth: `all ${duration.base} ${easing.smooth}`,
} as const;
/**
* Complete animations export
*/
export const animations = {
duration,
easing,
keyframes,
transitions,
} as const;

View file

@ -0,0 +1,72 @@
/**
* Border token system for TPMJS UI
* 1px borders with dotted/dashed variants for technical aesthetic
*/
/**
* Border widths
*/
export const borderWidth = {
DEFAULT: "1px",
0: "0",
2: "2px",
4: "4px",
8: "8px",
} as const;
/**
* Border styles
*/
export const borderStyle = {
solid: "solid",
dashed: "dashed",
dotted: "dotted",
none: "none",
} as const;
/**
* Border radius values
* References CSS variables for theme consistency
*/
export const borderRadius = {
none: "0",
sm: "var(--radius-sm)",
md: "var(--radius-md)",
lg: "var(--radius-lg)",
xl: "var(--radius-xl)",
"2xl": "var(--radius-2xl, 1rem)",
"3xl": "var(--radius-3xl, 1.5rem)",
full: "9999px",
} as const;
/**
* CSS variable border radius tokens
* Used in globals.css
*/
export const borderRadiusVars = {
sm: "4px",
md: "6px",
lg: "8px",
xl: "12px",
} as const;
/**
* Border color references (use CSS variables)
*/
export const borderColor = {
DEFAULT: "hsl(var(--border))",
strong: "hsl(var(--border-strong))",
subtle: "hsl(var(--border-subtle))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
} as const;
/**
* Complete border export
*/
export const borders = {
width: borderWidth,
style: borderStyle,
radius: borderRadius,
color: borderColor,
} as const;

View file

@ -0,0 +1,160 @@
/**
* Color token system for TPMJS UI
* Dark mode first approach with Vercel/Cursor/Perplexity aesthetic
*
* Colors use HSL format for easy dark mode implementation
*/
/**
* Neutral grayscale palette (16 stops for granular control)
* Optimized for dark mode with subtle, desaturated tones
*/
export const neutral = {
0: "#000000",
50: "#0a0a0a",
100: "#171717",
200: "#262626",
300: "#404040",
400: "#525252",
500: "#737373",
600: "#a3a3a3",
700: "#d4d4d4",
800: "#e5e5e5",
900: "#f5f5f5",
950: "#fafafa",
1000: "#ffffff",
} as const;
/**
* Semantic color tokens
* Reference CSS variables for theme-aware colors
*/
export const semantic = {
background: {
DEFAULT: "hsl(var(--background))",
surface: "hsl(var(--surface))",
elevated: "hsl(var(--surface-elevated))",
overlay: "hsl(var(--surface-overlay))",
},
foreground: {
DEFAULT: "hsl(var(--foreground))",
secondary: "hsl(var(--foreground-secondary))",
tertiary: "hsl(var(--foreground-tertiary))",
muted: "hsl(var(--foreground-muted))",
},
border: {
DEFAULT: "hsl(var(--border))",
strong: "hsl(var(--border-strong))",
subtle: "hsl(var(--border-subtle))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
} as const;
/**
* Status colors (subtle, desaturated for dark mode)
*/
export const status = {
success: {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))",
},
error: {
DEFAULT: "hsl(var(--error))",
foreground: "hsl(var(--error-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
info: {
DEFAULT: "hsl(var(--info))",
foreground: "hsl(var(--info-foreground))",
},
} as const;
/**
* Micro-accent colors for dark mode highlights
* Used sparingly for visual interest
*/
export const accents = {
blue: "#60a5fa",
purple: "#a78bfa",
green: "#34d399",
pink: "#f472b6",
orange: "#fb923c",
yellow: "#fbbf24",
} as const;
/**
* Form element colors
*/
export const form = {
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
ringOffset: "hsl(var(--ring-offset))",
} as const;
/**
* Grid/blueprint pattern colors
*/
export const grid = {
color: "hsl(var(--grid-color))",
size: "var(--grid-size)",
} as const;
/**
* Card colors
*/
export const card = {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
} as const;
/**
* Legacy destructive color (for backward compatibility)
*/
export const destructive = {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
} as const;
/**
* Complete color export
*/
export const colors = {
neutral,
semantic,
status,
accents,
form,
grid,
card,
destructive,
// Flat exports for easy access
background: semantic.background.DEFAULT,
foreground: semantic.foreground.DEFAULT,
border: semantic.border.DEFAULT,
primary: semantic.primary.DEFAULT,
secondary: semantic.secondary.DEFAULT,
accent: semantic.accent.DEFAULT,
muted: semantic.muted.DEFAULT,
success: status.success.DEFAULT,
error: status.error.DEFAULT,
warning: status.warning.DEFAULT,
info: status.info.DEFAULT,
} as const;

View file

@ -0,0 +1,11 @@
/**
* Global design tokens for TPMJS UI
* Export all token categories
*/
export * from "./colors";
export * from "./typography";
export * from "./spacing";
export * from "./borders";
export * from "./shadows";
export * from "./animations";

View file

@ -0,0 +1,55 @@
/**
* Shadow token system for TPMJS UI
* Subtle shadows for dark mode with minimal elevation
*/
/**
* Box shadow definitions
* Subtle and understated for technical aesthetic
*/
export const boxShadow = {
none: "none",
sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
base: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
md: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
lg: "0 10px 15px -3px rgb(0 0 0 / 0.2), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
xl: "0 20px 25px -5px rgb(0 0 0 / 0.2), 0 8px 10px -6px rgb(0 0 0 / 0.1)",
"2xl": "0 25px 50px -12px rgb(0 0 0 / 0.3)",
inner: "inset 0 2px 4px 0 rgb(0 0 0 / 0.05)",
} as const;
/**
* Drop shadow definitions (for SVG and images)
*/
export const dropShadow = {
none: "none",
sm: "drop-shadow(0 1px 1px rgb(0 0 0 / 0.05))",
base: "drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow(0 1px 1px rgb(0 0 0 / 0.06))",
md: "drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06))",
lg: "drop-shadow(0 10px 8px rgb(0 0 0 / 0.04)) drop-shadow(0 4px 3px rgb(0 0 0 / 0.1))",
xl: "drop-shadow(0 20px 13px rgb(0 0 0 / 0.03)) drop-shadow(0 8px 5px rgb(0 0 0 / 0.08))",
"2xl": "drop-shadow(0 25px 25px rgb(0 0 0 / 0.15))",
} as const;
/**
* Elevation system
* Semantic elevation levels for layered interfaces
*/
export const elevation = {
0: boxShadow.none,
1: boxShadow.sm,
2: boxShadow.base,
3: boxShadow.md,
4: boxShadow.lg,
5: boxShadow.xl,
6: boxShadow["2xl"],
} as const;
/**
* Complete shadows export
*/
export const shadows = {
box: boxShadow,
drop: dropShadow,
elevation,
} as const;

View file

@ -0,0 +1,92 @@
/**
* Spacing token system for TPMJS UI
* Based on 4px base unit for consistent rhythm
*/
/**
* Spacing scale
* Uses rem units for accessibility (scales with user font size)
*/
export const spacing = {
px: "1px",
0: "0",
0.5: "0.125rem", // 2px
1: "0.25rem", // 4px
1.5: "0.375rem", // 6px
2: "0.5rem", // 8px
2.5: "0.625rem", // 10px
3: "0.75rem", // 12px
3.5: "0.875rem", // 14px
4: "1rem", // 16px
5: "1.25rem", // 20px
6: "1.5rem", // 24px
7: "1.75rem", // 28px
8: "2rem", // 32px
9: "2.25rem", // 36px
10: "2.5rem", // 40px
11: "2.75rem", // 44px
12: "3rem", // 48px
14: "3.5rem", // 56px
16: "4rem", // 64px
20: "5rem", // 80px
24: "6rem", // 96px
28: "7rem", // 112px
32: "8rem", // 128px
36: "9rem", // 144px
40: "10rem", // 160px
44: "11rem", // 176px
48: "12rem", // 192px
52: "13rem", // 208px
56: "14rem", // 224px
60: "15rem", // 240px
64: "16rem", // 256px
72: "18rem", // 288px
80: "20rem", // 320px
96: "24rem", // 384px
} as const;
/**
* Container widths
*/
export const containerWidth = {
xs: "20rem", // 320px
sm: "24rem", // 384px
md: "28rem", // 448px
lg: "32rem", // 512px
xl: "36rem", // 576px
"2xl": "42rem", // 672px
"3xl": "48rem", // 768px
"4xl": "56rem", // 896px
"5xl": "64rem", // 1024px
"6xl": "72rem", // 1152px
"7xl": "80rem", // 1280px
full: "100%",
screen: "100vw",
} as const;
/**
* Component-specific spacing presets
*/
export const component = {
/** Padding for buttons, inputs, etc. */
padding: {
sm: spacing[3], // 12px
md: spacing[4], // 16px
lg: spacing[5], // 20px
},
/** Gap between elements */
gap: {
sm: spacing[2], // 8px
md: spacing[3], // 12px
lg: spacing[4], // 16px
},
/** Margin between sections */
margin: {
sm: spacing[4], // 16px
md: spacing[6], // 24px
lg: spacing[8], // 32px
xl: spacing[12], // 48px
},
} as const;

View file

@ -0,0 +1,98 @@
/**
* Typography token system for TPMJS UI
* Geist-inspired with generous spacing and technical feel
*/
/**
* Font families
* Geist font stack with system fallbacks
*/
export const fontFamily = {
sans: [
"Geist",
"-apple-system",
"BlinkMacSystemFont",
'"Segoe UI"',
"Roboto",
'"Helvetica Neue"',
"Arial",
"sans-serif",
],
mono: [
"Geist Mono",
'"SF Mono"',
"Monaco",
"Consolas",
'"Liberation Mono"',
'"Courier New"',
"monospace",
],
} as const;
/**
* Font sizes with line heights and letter spacing
* Generous line heights for readability
* Tighter letter spacing for larger sizes (optical sizing)
*/
export const fontSize = {
xs: ["0.75rem", { lineHeight: "1rem", letterSpacing: "0" }],
sm: ["0.875rem", { lineHeight: "1.25rem", letterSpacing: "0" }],
base: ["1rem", { lineHeight: "1.5rem", letterSpacing: "0" }],
lg: ["1.125rem", { lineHeight: "1.75rem", letterSpacing: "-0.01em" }],
xl: ["1.25rem", { lineHeight: "1.75rem", letterSpacing: "-0.01em" }],
"2xl": ["1.5rem", { lineHeight: "2rem", letterSpacing: "-0.02em" }],
"3xl": ["1.875rem", { lineHeight: "2.25rem", letterSpacing: "-0.02em" }],
"4xl": ["2.25rem", { lineHeight: "2.5rem", letterSpacing: "-0.03em" }],
"5xl": ["3rem", { lineHeight: "1", letterSpacing: "-0.03em" }],
"6xl": ["3.75rem", { lineHeight: "1", letterSpacing: "-0.04em" }],
"7xl": ["4.5rem", { lineHeight: "1", letterSpacing: "-0.04em" }],
"8xl": ["6rem", { lineHeight: "1", letterSpacing: "-0.05em" }],
"9xl": ["8rem", { lineHeight: "1", letterSpacing: "-0.05em" }],
} as const;
/**
* Font weights
*/
export const fontWeight = {
light: "300",
normal: "400",
medium: "500",
semibold: "600",
bold: "700",
extrabold: "800",
} as const;
/**
* Letter spacing values
*/
export const letterSpacing = {
tighter: "-0.05em",
tight: "-0.025em",
normal: "0",
wide: "0.025em",
wider: "0.05em",
widest: "0.1em",
} as const;
/**
* Line heights
*/
export const lineHeight = {
none: "1",
tight: "1.25",
snug: "1.375",
normal: "1.5",
relaxed: "1.625",
loose: "2",
} as const;
/**
* Complete typography export
*/
export const typography = {
fontFamily,
fontSize,
fontWeight,
letterSpacing,
lineHeight,
} as const;

View file

@ -1,14 +1,43 @@
import { defineConfig } from 'tsup';
import path from "node:path";
import { glob } from "glob";
import { defineConfig } from "tsup";
/**
* Auto-discover all component entry points
* Looks for src/ComponentName/ComponentName.ts files where the folder name matches the file name
*/
const allFiles = glob.sync("src/**/[A-Z]*.ts", {
ignore: [
"**/*.test.ts",
"**/*.stories.ts",
"**/types.ts",
"**/tokens.ts",
"**/variants.ts",
"**/index.ts",
"src/test-setup.ts",
"src/tokens/**",
"src/system/**",
],
});
// Filter to only include files where the folder name matches the file name
// e.g., src/Button/Button.ts is included, but src/Button/helpers.ts is not
const entries = allFiles.filter((file) => {
const dir = path.dirname(file);
const folderName = path.basename(dir);
const fileName = path.basename(file, ".ts");
return folderName === fileName;
});
export default defineConfig({
entry: ['src/Button/Button.ts', 'src/Card/Card.ts'],
format: ['esm'],
dts: true,
clean: true,
treeshake: true,
splitting: false,
external: ['react', 'react-dom'],
esbuildOptions(options) {
options.jsx = 'automatic';
},
entry: entries,
format: ["esm"],
dts: true,
clean: true,
treeshake: true,
splitting: false,
external: ["react", "react-dom"],
esbuildOptions(options) {
options.jsx = "automatic";
},
});

64
pnpm-lock.yaml generated
View file

@ -44,6 +44,9 @@ importers:
next:
specifier: ^16.0.4
version: 16.0.4(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react:
specifier: ^19.0.0
version: 19.2.0
@ -268,6 +271,9 @@ importers:
'@types/react-dom':
specifier: ^19.0.2
version: 19.2.3(@types/react@19.2.7)
glob:
specifier: ^13.0.0
version: 13.0.0
happy-dom:
specifier: ^15.11.7
version: 15.11.7
@ -1194,6 +1200,14 @@ packages:
'@types/node':
optional: true
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
'@isaacs/brace-expansion@5.0.0':
resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
engines: {node: 20 || >=22}
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@ -2635,6 +2649,10 @@ packages:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
hasBin: true
glob@13.0.0:
resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==}
engines: {node: 20 || >=22}
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
@ -3047,6 +3065,10 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.2:
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@ -3083,6 +3105,10 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
minimatch@10.1.1:
resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
engines: {node: 20 || >=22}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@ -3137,6 +3163,12 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@16.0.4:
resolution: {integrity: sha512-vICcxKusY8qW7QFOzTvnRL1ejz2ClTqDKtm1AcUjm2mPv/lVAdgpGNsftsPRIDJOXOjRQO68i1dM8Lp8GZnqoA==}
engines: {node: '>=20.9.0'}
@ -3276,6 +3308,10 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-scurry@2.0.1:
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
engines: {node: 20 || >=22}
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
@ -4845,6 +4881,12 @@ snapshots:
optionalDependencies:
'@types/node': 22.19.1
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0':
dependencies:
'@isaacs/balanced-match': 4.0.1
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@ -6585,6 +6627,12 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
glob@13.0.0:
dependencies:
minimatch: 10.1.1
minipass: 7.1.2
path-scurry: 2.0.1
globals@14.0.0: {}
globals@16.4.0: {}
@ -6958,6 +7006,8 @@ snapshots:
lru-cache@10.4.3: {}
lru-cache@11.2.2: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@ -6989,6 +7039,10 @@ snapshots:
min-indent@1.0.1: {}
minimatch@10.1.1:
dependencies:
'@isaacs/brace-expansion': 5.0.0
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
@ -7051,6 +7105,11 @@ snapshots:
natural-compare@1.4.0: {}
next-themes@0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
next@16.0.4(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.0.4
@ -7194,6 +7253,11 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
path-scurry@2.0.1:
dependencies:
lru-cache: 11.2.2
minipass: 7.1.2
path-to-regexp@6.3.0: {}
path-type@4.0.0: {}