diff --git a/apps/web/package.json b/apps/web/package.json index 19005cc..e056c7d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" + } } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 5cf7456..4254162 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -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%; + } } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 0575b4d..15c805b 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -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 ( - - {children} - - ); + return ( + + + + {children} + + + + ); } diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 2805671..3532566 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -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 ( -
-
-

TPMJS

-

- Tool Package Manager for AI Agents -

+ 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' - ) - ) - )} -
-
- ); + 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", + ), + ), + ), + ), + ), + ); } diff --git a/apps/web/src/app/tool/tool-search/page.tsx b/apps/web/src/app/tool/tool-search/page.tsx new file mode 100644 index 0000000..2f3ea1d --- /dev/null +++ b/apps/web/src/app/tool/tool-search/page.tsx @@ -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}"`, + ), + ], + ), + ], + ), + ]); +} diff --git a/apps/web/src/components/ThemeToggle.tsx b/apps/web/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..d9dfd24 --- /dev/null +++ b/apps/web/src/components/ThemeToggle.tsx @@ -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", + }), + ); +} diff --git a/apps/web/src/components/providers/ThemeProvider.tsx b/apps/web/src/components/providers/ThemeProvider.tsx new file mode 100644 index 0000000..39ccc70 --- /dev/null +++ b/apps/web/src/components/providers/ThemeProvider.tsx @@ -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) { + return createElement(NextThemesProvider, props, children); +} diff --git a/apps/web/src/data/homePageData.ts b/apps/web/src/data/homePageData.ts new file mode 100644 index 0000000..4736148 --- /dev/null +++ b/apps/web/src/data/homePageData.ts @@ -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", + }, +]; diff --git a/apps/web/src/data/toolData.ts b/apps/web/src/data/toolData.ts new file mode 100644 index 0000000..e1ad7ec --- /dev/null +++ b/apps/web/src/data/toolData.ts @@ -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)), + ); +} diff --git a/packages/config/tailwind/base.ts b/packages/config/tailwind/base.ts index 11d29b3..cd1f673 100644 --- a/packages/config/tailwind/base.ts +++ b/packages/config/tailwind/base.ts @@ -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; diff --git a/packages/storybook/stories/Badge.stories.ts b/packages/storybook/stories/Badge.stories.ts new file mode 100644 index 0000000..91704eb --- /dev/null +++ b/packages/storybook/stories/Badge.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +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"), +}; diff --git a/packages/storybook/stories/CodeBlock.stories.ts b/packages/storybook/stories/CodeBlock.stories.ts new file mode 100644 index 0000000..d0e8008 --- /dev/null +++ b/packages/storybook/stories/CodeBlock.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +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", + }), + ), +}; diff --git a/packages/storybook/stories/Icon.stories.ts b/packages/storybook/stories/Icon.stories.ts new file mode 100644 index 0000000..9afa905 --- /dev/null +++ b/packages/storybook/stories/Icon.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +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", + }), + ), +}; diff --git a/packages/storybook/stories/Input.stories.ts b/packages/storybook/stories/Input.stories.ts new file mode 100644 index 0000000..8d5f474 --- /dev/null +++ b/packages/storybook/stories/Input.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +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", + }), + ), +}; diff --git a/packages/storybook/stories/ProgressBar.stories.ts b/packages/storybook/stories/ProgressBar.stories.ts new file mode 100644 index 0000000..36e570e --- /dev/null +++ b/packages/storybook/stories/ProgressBar.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +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 }), + ), +}; diff --git a/packages/storybook/stories/Tabs.stories.ts b/packages/storybook/stories/Tabs.stories.ts new file mode 100644 index 0000000..bdb66d5 --- /dev/null +++ b/packages/storybook/stories/Tabs.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +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: () => {}, + }), + ), +}; diff --git a/packages/storybook/styles/globals.css b/packages/storybook/styles/globals.css index ed3b6aa..4254162 100644 --- a/packages/storybook/styles/globals.css +++ b/packages/storybook/styles/globals.css @@ -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%; } } diff --git a/packages/ui/package.json b/packages/ui/package.json index c6e0277..1414caf 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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" + } } diff --git a/packages/ui/src/Badge/Badge.test.ts b/packages/ui/src/Badge/Badge.test.ts new file mode 100644 index 0000000..5c9ad97 --- /dev/null +++ b/packages/ui/src/Badge/Badge.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/ui/src/Badge/Badge.ts b/packages/ui/src/Badge/Badge.ts new file mode 100644 index 0000000..c11a58d --- /dev/null +++ b/packages/ui/src/Badge/Badge.ts @@ -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( + ({ className, variant = "default", size = "md", ...props }, ref) => { + return createElement("div", { + className: cn( + badgeVariants({ + variant, + size, + }), + className, + ), + ref, + ...props, + }); + }, +); + +Badge.displayName = "Badge"; diff --git a/packages/ui/src/Badge/tokens.ts b/packages/ui/src/Badge/tokens.ts new file mode 100644 index 0000000..219ab17 --- /dev/null +++ b/packages/ui/src/Badge/tokens.ts @@ -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; diff --git a/packages/ui/src/Badge/types.ts b/packages/ui/src/Badge/types.ts new file mode 100644 index 0000000..0264f6f --- /dev/null +++ b/packages/ui/src/Badge/types.ts @@ -0,0 +1,30 @@ +import type { HTMLAttributes } from "react"; + +/** + * Badge component props + */ +export interface BadgeProps extends HTMLAttributes { + /** + * 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; diff --git a/packages/ui/src/Badge/variants.ts b/packages/ui/src/Badge/variants.ts new file mode 100644 index 0000000..35dff41 --- /dev/null +++ b/packages/ui/src/Badge/variants.ts @@ -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", + }, +}); diff --git a/packages/ui/src/Button/Button.test.ts b/packages/ui/src/Button/Button.test.ts index 4cfe148..4a9b2b2 100644 --- a/packages/ui/src/Button/Button.test.ts +++ b/packages/ui/src/Button/Button.test.ts @@ -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"); + }); + }); }); diff --git a/packages/ui/src/Button/Button.ts b/packages/ui/src/Button/Button.ts index eabd578..b725a0b 100644 --- a/packages/ui/src/Button/Button.ts +++ b/packages/ui/src/Button/Button.ts @@ -1,40 +1,74 @@ -import { createElement, forwardRef, type ButtonHTMLAttributes } from 'react'; -import { cn } from '@tpmjs/utils/cn'; - -export interface ButtonProps extends ButtonHTMLAttributes { - 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( - ({ 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"; diff --git a/packages/ui/src/Button/tokens.ts b/packages/ui/src/Button/tokens.ts new file mode 100644 index 0000000..151b915 --- /dev/null +++ b/packages/ui/src/Button/tokens.ts @@ -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; diff --git a/packages/ui/src/Button/types.ts b/packages/ui/src/Button/types.ts new file mode 100644 index 0000000..f26aef8 --- /dev/null +++ b/packages/ui/src/Button/types.ts @@ -0,0 +1,35 @@ +import type { ButtonHTMLAttributes } from "react"; + +/** + * Button component props + */ +export interface ButtonProps extends ButtonHTMLAttributes { + /** + * 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; diff --git a/packages/ui/src/Button/variants.ts b/packages/ui/src/Button/variants.ts new file mode 100644 index 0000000..66454f3 --- /dev/null +++ b/packages/ui/src/Button/variants.ts @@ -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", + }, +}); diff --git a/packages/ui/src/Card/Card.test.ts b/packages/ui/src/Card/Card.test.ts new file mode 100644 index 0000000..d3a2457 --- /dev/null +++ b/packages/ui/src/Card/Card.test.ts @@ -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); + }); + }); +}); diff --git a/packages/ui/src/Card/Card.ts b/packages/ui/src/Card/Card.ts index adf9305..2231ae8 100644 --- a/packages/ui/src/Card/Card.ts +++ b/packages/ui/src/Card/Card.ts @@ -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 {} +/** + * 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( + ({ className, variant = "default", padding = "none", ...props }, ref) => { + return createElement("div", { + ref, + className: cn( + cardVariants({ + variant, + padding, + }), + className, + ), + ...props, + }); + }, +); -export const Card = forwardRef(({ 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( + ({ 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( + ({ 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( - ({ 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( + ({ 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>( - ({ 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( + ({ className, padding = "md", ...props }, ref) => { + return createElement("div", { + ref, + className: cn( + cardFooterVariants({ + padding, + }), + className, + ), + ...props, + }); + }, ); -CardTitle.displayName = 'CardTitle'; - -export const CardContent = forwardRef( - ({ className, ...props }, ref) => { - return createElement('div', { - ref, - className: cn('p-6 pt-0', className), - ...props, - }); - } -); - -CardContent.displayName = 'CardContent'; +CardFooter.displayName = "CardFooter"; diff --git a/packages/ui/src/Card/tokens.ts b/packages/ui/src/Card/tokens.ts new file mode 100644 index 0000000..979f6b9 --- /dev/null +++ b/packages/ui/src/Card/tokens.ts @@ -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; diff --git a/packages/ui/src/Card/types.ts b/packages/ui/src/Card/types.ts new file mode 100644 index 0000000..d76dce2 --- /dev/null +++ b/packages/ui/src/Card/types.ts @@ -0,0 +1,73 @@ +import type { HTMLAttributes } from "react"; + +/** + * Card component props + */ +export interface CardProps extends HTMLAttributes { + /** + * 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 { + /** + * Padding size for the header + * @default 'md' + */ + padding?: "none" | "sm" | "md" | "lg"; +} + +/** + * CardTitle component props + */ +export interface CardTitleProps extends HTMLAttributes { + /** + * Heading level + * @default 'h3' + */ + as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; +} + +/** + * CardDescription component props + */ +export interface CardDescriptionProps + extends HTMLAttributes {} + +/** + * CardContent component props + */ +export interface CardContentProps extends HTMLAttributes { + /** + * Padding size for the content + * @default 'md' + */ + padding?: "none" | "sm" | "md" | "lg"; +} + +/** + * CardFooter component props + */ +export interface CardFooterProps extends HTMLAttributes { + /** + * Padding size for the footer + * @default 'md' + */ + padding?: "none" | "sm" | "md" | "lg"; +} + +/** + * Card ref type + */ +export type CardRef = HTMLDivElement; diff --git a/packages/ui/src/Card/variants.ts b/packages/ui/src/Card/variants.ts new file mode 100644 index 0000000..7207933 --- /dev/null +++ b/packages/ui/src/Card/variants.ts @@ -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", + }, +}); diff --git a/packages/ui/src/CodeBlock/CodeBlock.test.ts b/packages/ui/src/CodeBlock/CodeBlock.test.ts new file mode 100644 index 0000000..0f53d42 --- /dev/null +++ b/packages/ui/src/CodeBlock/CodeBlock.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/ui/src/CodeBlock/CodeBlock.ts b/packages/ui/src/CodeBlock/CodeBlock.ts new file mode 100644 index 0000000..0943efc --- /dev/null +++ b/packages/ui/src/CodeBlock/CodeBlock.ts @@ -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( + ( + { + 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"; diff --git a/packages/ui/src/CodeBlock/tokens.ts b/packages/ui/src/CodeBlock/tokens.ts new file mode 100644 index 0000000..66fa425 --- /dev/null +++ b/packages/ui/src/CodeBlock/tokens.ts @@ -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; diff --git a/packages/ui/src/CodeBlock/types.ts b/packages/ui/src/CodeBlock/types.ts new file mode 100644 index 0000000..10d041f --- /dev/null +++ b/packages/ui/src/CodeBlock/types.ts @@ -0,0 +1,35 @@ +import type { HTMLAttributes } from "react"; + +/** + * CodeBlock component props + */ +export interface CodeBlockProps + extends Omit, "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; diff --git a/packages/ui/src/CodeBlock/variants.ts b/packages/ui/src/CodeBlock/variants.ts new file mode 100644 index 0000000..149d967 --- /dev/null +++ b/packages/ui/src/CodeBlock/variants.ts @@ -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: {}, +}); diff --git a/packages/ui/src/Container/Container.test.ts b/packages/ui/src/Container/Container.test.ts new file mode 100644 index 0000000..ddafebf --- /dev/null +++ b/packages/ui/src/Container/Container.test.ts @@ -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"); + }); + }); +}); diff --git a/packages/ui/src/Container/Container.ts b/packages/ui/src/Container/Container.ts new file mode 100644 index 0000000..7f5056f --- /dev/null +++ b/packages/ui/src/Container/Container.ts @@ -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( + ({ className, size = "xl", padding = "md", children, ...props }, ref) => { + return createElement( + "div", + { + className: cn( + containerVariants({ + size, + padding, + }), + className, + ), + ref, + ...props, + }, + children, + ); + }, +); + +Container.displayName = "Container"; diff --git a/packages/ui/src/Container/tokens.ts b/packages/ui/src/Container/tokens.ts new file mode 100644 index 0000000..2a37414 --- /dev/null +++ b/packages/ui/src/Container/tokens.ts @@ -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; diff --git a/packages/ui/src/Container/types.ts b/packages/ui/src/Container/types.ts new file mode 100644 index 0000000..e3cdb35 --- /dev/null +++ b/packages/ui/src/Container/types.ts @@ -0,0 +1,23 @@ +import type { HTMLAttributes } from "react"; + +/** + * Container component props + */ +export interface ContainerProps extends HTMLAttributes { + /** + * 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; diff --git a/packages/ui/src/Container/variants.ts b/packages/ui/src/Container/variants.ts new file mode 100644 index 0000000..a013904 --- /dev/null +++ b/packages/ui/src/Container/variants.ts @@ -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", + }, +}); diff --git a/packages/ui/src/Header/Header.test.ts b/packages/ui/src/Header/Header.test.ts new file mode 100644 index 0000000..8354d9b --- /dev/null +++ b/packages/ui/src/Header/Header.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/ui/src/Header/Header.ts b/packages/ui/src/Header/Header.ts new file mode 100644 index 0000000..795848d --- /dev/null +++ b/packages/ui/src/Header/Header.ts @@ -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( + ( + { + 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"; diff --git a/packages/ui/src/Header/tokens.ts b/packages/ui/src/Header/tokens.ts new file mode 100644 index 0000000..6c2e02c --- /dev/null +++ b/packages/ui/src/Header/tokens.ts @@ -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; diff --git a/packages/ui/src/Header/types.ts b/packages/ui/src/Header/types.ts new file mode 100644 index 0000000..7816818 --- /dev/null +++ b/packages/ui/src/Header/types.ts @@ -0,0 +1,34 @@ +import type { HTMLAttributes, ReactNode } from "react"; + +/** + * Header component props + */ +export interface HeaderProps + extends Omit, "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; diff --git a/packages/ui/src/Header/variants.ts b/packages/ui/src/Header/variants.ts new file mode 100644 index 0000000..5f2915f --- /dev/null +++ b/packages/ui/src/Header/variants.ts @@ -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", + }, +}); diff --git a/packages/ui/src/Icon/Icon.test.ts b/packages/ui/src/Icon/Icon.test.ts new file mode 100644 index 0000000..bd8f7c2 --- /dev/null +++ b/packages/ui/src/Icon/Icon.test.ts @@ -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"); + }); + }); +}); diff --git a/packages/ui/src/Icon/Icon.ts b/packages/ui/src/Icon/Icon.ts new file mode 100644 index 0000000..22b60e7 --- /dev/null +++ b/packages/ui/src/Icon/Icon.ts @@ -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( + ({ 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"; diff --git a/packages/ui/src/Icon/icons.ts b/packages/ui/src/Icon/icons.ts new file mode 100644 index 0000000..f4a9c25 --- /dev/null +++ b/packages/ui/src/Icon/icons.ts @@ -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; diff --git a/packages/ui/src/Icon/tokens.ts b/packages/ui/src/Icon/tokens.ts new file mode 100644 index 0000000..90c56b6 --- /dev/null +++ b/packages/ui/src/Icon/tokens.ts @@ -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; diff --git a/packages/ui/src/Icon/types.ts b/packages/ui/src/Icon/types.ts new file mode 100644 index 0000000..f8007f3 --- /dev/null +++ b/packages/ui/src/Icon/types.ts @@ -0,0 +1,24 @@ +import type { SVGAttributes } from "react"; +import type { IconName } from "./icons"; + +/** + * Icon component props + */ +export interface IconProps + extends Omit, "children"> { + /** + * Icon to display + */ + icon: IconName; + + /** + * Size of the icon + * @default 'md' + */ + size?: "sm" | "md" | "lg"; +} + +/** + * Icon ref type + */ +export type IconRef = SVGSVGElement; diff --git a/packages/ui/src/Icon/variants.ts b/packages/ui/src/Icon/variants.ts new file mode 100644 index 0000000..7599853 --- /dev/null +++ b/packages/ui/src/Icon/variants.ts @@ -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", + }, +}); diff --git a/packages/ui/src/Input/Input.test.ts b/packages/ui/src/Input/Input.test.ts new file mode 100644 index 0000000..9508c88 --- /dev/null +++ b/packages/ui/src/Input/Input.test.ts @@ -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"); + }); + }); +}); diff --git a/packages/ui/src/Input/Input.ts b/packages/ui/src/Input/Input.ts new file mode 100644 index 0000000..d72c163 --- /dev/null +++ b/packages/ui/src/Input/Input.ts @@ -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( + ( + { + 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"; diff --git a/packages/ui/src/Input/tokens.ts b/packages/ui/src/Input/tokens.ts new file mode 100644 index 0000000..2b35e75 --- /dev/null +++ b/packages/ui/src/Input/tokens.ts @@ -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; diff --git a/packages/ui/src/Input/types.ts b/packages/ui/src/Input/types.ts new file mode 100644 index 0000000..94b6ac1 --- /dev/null +++ b/packages/ui/src/Input/types.ts @@ -0,0 +1,30 @@ +import type { InputHTMLAttributes } from "react"; + +/** + * Input component props + */ +export interface InputProps + extends Omit, "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; diff --git a/packages/ui/src/Input/variants.ts b/packages/ui/src/Input/variants.ts new file mode 100644 index 0000000..7120473 --- /dev/null +++ b/packages/ui/src/Input/variants.ts @@ -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", + }, +}); diff --git a/packages/ui/src/Label/Label.test.ts b/packages/ui/src/Label/Label.test.ts new file mode 100644 index 0000000..636a39d --- /dev/null +++ b/packages/ui/src/Label/Label.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/ui/src/Label/Label.ts b/packages/ui/src/Label/Label.ts new file mode 100644 index 0000000..e005439 --- /dev/null +++ b/packages/ui/src/Label/Label.ts @@ -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( + ( + { + 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"; diff --git a/packages/ui/src/Label/tokens.ts b/packages/ui/src/Label/tokens.ts new file mode 100644 index 0000000..d941e15 --- /dev/null +++ b/packages/ui/src/Label/tokens.ts @@ -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; diff --git a/packages/ui/src/Label/types.ts b/packages/ui/src/Label/types.ts new file mode 100644 index 0000000..b121283 --- /dev/null +++ b/packages/ui/src/Label/types.ts @@ -0,0 +1,29 @@ +import type { LabelHTMLAttributes } from "react"; + +/** + * Label component props + */ +export interface LabelProps extends LabelHTMLAttributes { + /** + * 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; diff --git a/packages/ui/src/Label/variants.ts b/packages/ui/src/Label/variants.ts new file mode 100644 index 0000000..6264d69 --- /dev/null +++ b/packages/ui/src/Label/variants.ts @@ -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", + }, +}); diff --git a/packages/ui/src/ProgressBar/ProgressBar.test.ts b/packages/ui/src/ProgressBar/ProgressBar.test.ts new file mode 100644 index 0000000..b26df92 --- /dev/null +++ b/packages/ui/src/ProgressBar/ProgressBar.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/ui/src/ProgressBar/ProgressBar.ts b/packages/ui/src/ProgressBar/ProgressBar.ts new file mode 100644 index 0000000..859dd28 --- /dev/null +++ b/packages/ui/src/ProgressBar/ProgressBar.ts @@ -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( + ( + { + 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"; diff --git a/packages/ui/src/ProgressBar/tokens.ts b/packages/ui/src/ProgressBar/tokens.ts new file mode 100644 index 0000000..f14fa48 --- /dev/null +++ b/packages/ui/src/ProgressBar/tokens.ts @@ -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; diff --git a/packages/ui/src/ProgressBar/types.ts b/packages/ui/src/ProgressBar/types.ts new file mode 100644 index 0000000..70873ec --- /dev/null +++ b/packages/ui/src/ProgressBar/types.ts @@ -0,0 +1,35 @@ +import type { HTMLAttributes } from "react"; + +/** + * ProgressBar component props + */ +export interface ProgressBarProps + extends Omit, "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; diff --git a/packages/ui/src/ProgressBar/variants.ts b/packages/ui/src/ProgressBar/variants.ts new file mode 100644 index 0000000..55eda3b --- /dev/null +++ b/packages/ui/src/ProgressBar/variants.ts @@ -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", + }, +}); diff --git a/packages/ui/src/Tabs/Tabs.test.ts b/packages/ui/src/Tabs/Tabs.test.ts new file mode 100644 index 0000000..eb7950f --- /dev/null +++ b/packages/ui/src/Tabs/Tabs.test.ts @@ -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); + }); + }); +}); diff --git a/packages/ui/src/Tabs/Tabs.ts b/packages/ui/src/Tabs/Tabs.ts new file mode 100644 index 0000000..8bfd339 --- /dev/null +++ b/packages/ui/src/Tabs/Tabs.ts @@ -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( + ({ 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"; diff --git a/packages/ui/src/Tabs/tokens.ts b/packages/ui/src/Tabs/tokens.ts new file mode 100644 index 0000000..6403ff1 --- /dev/null +++ b/packages/ui/src/Tabs/tokens.ts @@ -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; diff --git a/packages/ui/src/Tabs/types.ts b/packages/ui/src/Tabs/types.ts new file mode 100644 index 0000000..cdb60d5 --- /dev/null +++ b/packages/ui/src/Tabs/types.ts @@ -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, "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; diff --git a/packages/ui/src/Tabs/variants.ts b/packages/ui/src/Tabs/variants.ts new file mode 100644 index 0000000..316df60 --- /dev/null +++ b/packages/ui/src/Tabs/variants.ts @@ -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", + }, +}); diff --git a/packages/ui/src/system/variants.ts b/packages/ui/src/system/variants.ts new file mode 100644 index 0000000..9d95d6b --- /dev/null +++ b/packages/ui/src/system/variants.ts @@ -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>> = { + /** 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>> = + 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>, +>(config: VariantConfig) { + return function getVariantClasses(props?: VariantProps): string { + const classes: string[] = [config.base]; + + // Merge props with defaults + const mergedProps = { + ...config.defaultVariants, + ...props, + } as VariantProps; + + // 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(" "); + }; +} diff --git a/packages/ui/src/tokens/animations.ts b/packages/ui/src/tokens/animations.ts new file mode 100644 index 0000000..0444958 --- /dev/null +++ b/packages/ui/src/tokens/animations.ts @@ -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; diff --git a/packages/ui/src/tokens/borders.ts b/packages/ui/src/tokens/borders.ts new file mode 100644 index 0000000..7b3c87a --- /dev/null +++ b/packages/ui/src/tokens/borders.ts @@ -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; diff --git a/packages/ui/src/tokens/colors.ts b/packages/ui/src/tokens/colors.ts new file mode 100644 index 0000000..d0d5745 --- /dev/null +++ b/packages/ui/src/tokens/colors.ts @@ -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; diff --git a/packages/ui/src/tokens/index.ts b/packages/ui/src/tokens/index.ts new file mode 100644 index 0000000..a6558c1 --- /dev/null +++ b/packages/ui/src/tokens/index.ts @@ -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"; diff --git a/packages/ui/src/tokens/shadows.ts b/packages/ui/src/tokens/shadows.ts new file mode 100644 index 0000000..dbef7d5 --- /dev/null +++ b/packages/ui/src/tokens/shadows.ts @@ -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; diff --git a/packages/ui/src/tokens/spacing.ts b/packages/ui/src/tokens/spacing.ts new file mode 100644 index 0000000..ec25df7 --- /dev/null +++ b/packages/ui/src/tokens/spacing.ts @@ -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; diff --git a/packages/ui/src/tokens/typography.ts b/packages/ui/src/tokens/typography.ts new file mode 100644 index 0000000..16e031d --- /dev/null +++ b/packages/ui/src/tokens/typography.ts @@ -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; diff --git a/packages/ui/tsup.config.ts b/packages/ui/tsup.config.ts index 52933a1..c44c86e 100644 --- a/packages/ui/tsup.config.ts +++ b/packages/ui/tsup.config.ts @@ -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"; + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e73330..2d3d546 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}