feat(ui): add comprehensive design system with 14 new components
New UI Components: - Modal/Dialog with sizes, focus trap, backdrop - Toast/Notification with variants, stacking, actions - Drawer/Sheet with directions and widths - Popover with triggers and positioning - Tooltip with delays and placements - DropdownMenu with items, dividers, keyboard nav - Breadcrumbs with separators and collapsing - Pagination with full/simple/minimal variants - Accordion with single/multi expand modes - Skeleton with text/avatar/card/table variants - InstallSnippet with package manager toggle - QualityScore with tier badges - ToolCard for registry display Design System Enhancements: - Complete token specification (shadows, radius, z-index, opacity) - Full dark mode palette in globals.css - Updated component variants for dark mode support Style Guide Expansion: - Split into 21 modular section files - Pattern library: navigation, forms, feedback, tables, search - Governance: a11y checklists, content guidelines, icon system - Interactive examples throughout
This commit is contained in:
parent
46b212e65a
commit
066e599293
77 changed files with 12045 additions and 195 deletions
|
|
@ -3,161 +3,393 @@
|
|||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
/* Light mode (default) */
|
||||
/* Light mode (default) - Warm earthy palette with copper accent */
|
||||
:root {
|
||||
/* Backgrounds & Surfaces - DRAMATIC CONTRAST */
|
||||
--background: 220 15% 96%; /* Light blue-gray background */
|
||||
--surface: 0 0% 100%; /* Pure white - cards really pop! */
|
||||
--surface-secondary: 220 15% 94%; /* Subtle gray for secondary surfaces */
|
||||
--surface-elevated: 0 0% 100%; /* White (elevated) */
|
||||
--surface-overlay: 0 0% 100%; /* White overlays */
|
||||
/* ===========================================
|
||||
COMPREHENSIVE COLOR PALETTE
|
||||
Warm, earthy tones with copper accent
|
||||
=========================================== */
|
||||
|
||||
/* Foreground (Text) */
|
||||
--foreground: 222 47% 11%; /* Almost black */
|
||||
--foreground-secondary: 215 25% 35%; /* Much darker for readability */
|
||||
--foreground-tertiary: 215 20% 50%; /* Medium gray */
|
||||
--foreground-muted: 215 16% 65%; /* Light gray */
|
||||
/* Backgrounds - Layered surface system */
|
||||
--background: 30 15% 95%; /* #F5F3F0 - Warm off-white base */
|
||||
--surface: 0 0% 100%; /* #FFFFFF - Cards, panels */
|
||||
--surface-2: 30 20% 97%; /* #FAF8F6 - Elevated surfaces */
|
||||
--surface-3: 25 12% 93%; /* #F0EDEA - Highest elevation */
|
||||
--surface-secondary: 30 10% 95%; /* Legacy alias */
|
||||
--surface-elevated: 30 20% 97%; /* Legacy alias */
|
||||
--surface-overlay: 0 0% 100%; /* Overlays, modals */
|
||||
|
||||
/* Borders - MUCH MORE VISIBLE */
|
||||
--border: 214 25% 80%; /* Strong medium gray */
|
||||
--border-strong: 214 30% 60%; /* Dark gray for emphasis */
|
||||
--border-subtle: 214 20% 88%; /* Subtle but visible */
|
||||
/* Text - Clear hierarchy */
|
||||
--foreground: 25 15% 10%; /* #1A1715 - Primary text */
|
||||
--foreground-secondary: 25 8% 35%; /* #5C564F - Secondary text */
|
||||
--foreground-tertiary: 25 6% 50%; /* #817B74 - Tertiary text */
|
||||
--foreground-muted: 25 5% 60%; /* #9A958F - Muted/hints */
|
||||
|
||||
/* Interactive States - MODERN & REFINED */
|
||||
--primary: 221 83% 53%; /* Sophisticated blue */
|
||||
--primary-foreground: 0 0% 100%; /* White text */
|
||||
--secondary: 220 15% 90%; /* Subtle gray-blue bg */
|
||||
--secondary-foreground: 222 47% 11%; /* Dark text */
|
||||
--accent: 221 75% 95%; /* Soft blue tint */
|
||||
--accent-foreground: 221 70% 35%; /* Rich blue text */
|
||||
--muted: 220 15% 92%; /* Subtle muted bg */
|
||||
--muted-foreground: 215 25% 40%; /* Darker muted text */
|
||||
/* Borders - Warm grays */
|
||||
--border: 30 8% 80%; /* #CEC9C3 - Default borders */
|
||||
--border-strong: 25 8% 50%; /* #857F77 - Emphasized borders */
|
||||
--border-subtle: 30 6% 88%; /* #E3E0DC - Subtle borders */
|
||||
|
||||
/* Status Colors - Modern Editorial Palette */
|
||||
--success: 152 57% 45%; /* Refined emerald green */
|
||||
--success-foreground: 0 0% 100%; /* White text on success */
|
||||
--error: 0 65% 51%; /* Sophisticated red, less harsh */
|
||||
--error-foreground: 0 0% 100%; /* White text on error */
|
||||
--warning: 36 100% 50%; /* Warm sophisticated amber */
|
||||
--warning-foreground: 0 0% 100%; /* White text on warning */
|
||||
--info: 210 100% 56%; /* Cool modern blue */
|
||||
--info-foreground: 0 0% 100%; /* White text on info */
|
||||
/* Primary - Copper accent #A6592D */
|
||||
--primary: 22 57% 41%; /* #A6592D - Brand copper */
|
||||
--primary-hover: 22 57% 35%; /* #8B4A26 - Hover state */
|
||||
--primary-active: 22 57% 30%; /* #753F20 - Active/pressed */
|
||||
--primary-foreground: 0 0% 100%; /* White text on primary */
|
||||
|
||||
/* Destructive (legacy) */
|
||||
--destructive: 0 65% 51%; /* Match error color */
|
||||
/* Accent - Copper variations */
|
||||
--accent: 22 57% 41%; /* #A6592D - Same as primary */
|
||||
--accent-strong: 22 60% 35%; /* #8F4722 - Darker/hover */
|
||||
--accent-muted: 22 30% 90%; /* #EDE5DF - Soft copper tint */
|
||||
--accent-foreground: 0 0% 100%; /* White text */
|
||||
|
||||
/* Secondary - Warm neutrals */
|
||||
--secondary: 30 10% 92%; /* #EDEBE8 - Soft warm bg */
|
||||
--secondary-hover: 30 10% 88%; /* #E3E0DC - Hover */
|
||||
--secondary-foreground: 25 15% 10%; /* Dark text */
|
||||
|
||||
/* Muted - Subtle backgrounds */
|
||||
--muted: 30 8% 94%; /* #F2F0ED - Muted background */
|
||||
--muted-foreground: 25 8% 40%; /* #6B655E - Muted text */
|
||||
|
||||
/* Status Colors - Earthy, warm variants */
|
||||
--success: 145 45% 36%; /* #327D52 - Forest green */
|
||||
--success-light: 145 35% 92%; /* #E5F3EC - Light bg */
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
--warning: 38 75% 50%; /* #D9A020 - Warm amber */
|
||||
--warning-light: 38 60% 92%; /* #F8F2E0 - Light bg */
|
||||
--warning-foreground: 25 15% 10%;
|
||||
|
||||
--error: 0 55% 50%; /* #C44545 - Warm red */
|
||||
--error-light: 0 50% 95%; /* #F9EDED - Light bg */
|
||||
--error-foreground: 0 0% 100%;
|
||||
|
||||
--info: 210 60% 50%; /* #3380CC - Cool blue */
|
||||
--info-light: 210 50% 95%; /* #EDF4FB - Light bg */
|
||||
--info-foreground: 0 0% 100%;
|
||||
|
||||
/* Destructive (alias for error) */
|
||||
--destructive: 0 55% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Form Elements */
|
||||
--input: 214 32% 91%;
|
||||
--ring: 222 84% 5%;
|
||||
--input: 30 8% 80%; /* Input borders */
|
||||
--input-focus: 22 57% 41%; /* Copper focus */
|
||||
--ring: 22 57% 41%; /* Focus ring - copper */
|
||||
--ring-offset: 0 0% 100%;
|
||||
|
||||
/* Grid/Blueprint Pattern */
|
||||
--grid-color: 214 32% 90%;
|
||||
--grid-size: 24px; /* Grid cell size */
|
||||
--grid-color: 25 8% 88%;
|
||||
--grid-size: 24px;
|
||||
|
||||
/* Card */
|
||||
--card: 0 0% 100%; /* Pure white - stands out dramatically on blue-gray background */
|
||||
--card-foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 25 15% 10%;
|
||||
|
||||
/* Border Radii */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius: var(--radius-md);
|
||||
/* Border Radii - SHARP CORNERS (0) */
|
||||
--radius-sm: 0;
|
||||
--radius-md: 0;
|
||||
--radius-lg: 0;
|
||||
--radius-xl: 0;
|
||||
--radius: 0;
|
||||
|
||||
/* Spacing - Editorial scale for airy layouts */
|
||||
--spacing-section: 6rem; /* 96px - Between major sections */
|
||||
--spacing-component: 3rem; /* 48px - Between components */
|
||||
--spacing-element: 1.5rem; /* 24px - Between related elements */
|
||||
--spacing-tight: 0.75rem; /* 12px - Tight groupings */
|
||||
--spacing-comfortable: 2rem; /* 32px - Comfortable breathing room */
|
||||
/* Spacing - Generous whitespace */
|
||||
--spacing-section: 8rem;
|
||||
--spacing-component: 4rem;
|
||||
--spacing-element: 2rem;
|
||||
--spacing-tight: 1rem;
|
||||
--spacing-comfortable: 3rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: var(--font-sans), -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
--font-mono: var(--font-mono), "SF Mono", Consolas, monospace;
|
||||
--font-sans: var(--font-inter), -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
--font-mono: var(--font-jetbrains), "JetBrains Mono", "SF Mono", Consolas, monospace;
|
||||
|
||||
/* Editorial Line Heights - Airy, readable */
|
||||
--leading-tight: 1.25;
|
||||
--leading-snug: 1.375;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.625;
|
||||
--leading-loose: 1.75;
|
||||
--leading-editorial: 1.8; /* Extra generous for body copy */
|
||||
/* Line Heights */
|
||||
--leading-tight: 1.2;
|
||||
--leading-snug: 1.4;
|
||||
--leading-normal: 1.6;
|
||||
--leading-relaxed: 1.7;
|
||||
--leading-loose: 1.8;
|
||||
--leading-editorial: 1.9;
|
||||
|
||||
/* Letter Spacing - Technical precision */
|
||||
--tracking-tighter: -0.05em;
|
||||
--tracking-tight: -0.025em;
|
||||
/* Letter Spacing */
|
||||
--tracking-tighter: -0.03em;
|
||||
--tracking-tight: -0.015em;
|
||||
--tracking-normal: 0;
|
||||
--tracking-wide: 0.025em;
|
||||
--tracking-wider: 0.05em;
|
||||
--tracking-widest: 0.1em;
|
||||
|
||||
/* Brutalist Accent Colors - Modern Editorial */
|
||||
--brutalist-accent: 221 83% 53%; /* Sophisticated blue */
|
||||
--brutalist-accent-hover: 221 83% 45%; /* Slightly deeper on hover */
|
||||
/* Brutalist Accents */
|
||||
--brutalist-accent: 22 57% 41%;
|
||||
--brutalist-accent-hover: 22 57% 35%;
|
||||
|
||||
/* Gradients */
|
||||
--gradient-start: 24 70% 50%;
|
||||
--gradient-end: 15 60% 20%;
|
||||
|
||||
/* ===========================================
|
||||
MOTION & ANIMATION TOKENS
|
||||
Motion communicates state, not decoration
|
||||
Fast by default (150-200ms)
|
||||
=========================================== */
|
||||
|
||||
/* Duration tokens */
|
||||
--motion-instant: 0ms;
|
||||
--motion-fast: 150ms;
|
||||
--motion-base: 200ms;
|
||||
--motion-slow: 300ms;
|
||||
--motion-slower: 500ms;
|
||||
|
||||
/* Easing tokens */
|
||||
--easing-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--easing-decelerate: cubic-bezier(0, 0, 0.2, 1);
|
||||
--easing-accelerate: cubic-bezier(0.4, 0, 1, 1);
|
||||
--easing-linear: linear;
|
||||
--easing-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
/* ===========================================
|
||||
TYPOGRAPHY SCALE
|
||||
Modular scale with 1.25 ratio
|
||||
Base: 16px
|
||||
=========================================== */
|
||||
|
||||
/* Type scale */
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 2rem; /* 32px */
|
||||
--text-4xl: 2.5rem; /* 40px */
|
||||
--text-5xl: 3rem; /* 48px */
|
||||
--text-6xl: 4rem; /* 64px */
|
||||
|
||||
/* Max line widths for readability */
|
||||
--prose-width: 65ch;
|
||||
--prose-width-narrow: 45ch;
|
||||
--prose-width-wide: 80ch;
|
||||
|
||||
/* ===========================================
|
||||
BREAKPOINTS
|
||||
Mobile-first responsive design
|
||||
=========================================== */
|
||||
|
||||
/* Breakpoint values (for reference, Tailwind handles classes) */
|
||||
--breakpoint-sm: 640px;
|
||||
--breakpoint-md: 768px;
|
||||
--breakpoint-lg: 1024px;
|
||||
--breakpoint-xl: 1280px;
|
||||
--breakpoint-2xl: 1536px;
|
||||
|
||||
/* ===========================================
|
||||
DENSITY MODES
|
||||
Compact / Comfortable / Spacious
|
||||
=========================================== */
|
||||
|
||||
/* Default is comfortable */
|
||||
--density-row-height: 48px;
|
||||
--density-cell-padding-x: 16px;
|
||||
--density-cell-padding-y: 12px;
|
||||
--density-icon-size: 20px;
|
||||
--density-font-size: var(--text-sm);
|
||||
|
||||
/* ===========================================
|
||||
Z-INDEX SCALE
|
||||
Consistent layering
|
||||
=========================================== */
|
||||
|
||||
--z-base: 0;
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-fixed: 300;
|
||||
--z-modal-backdrop: 400;
|
||||
--z-modal: 500;
|
||||
--z-popover: 600;
|
||||
--z-tooltip: 700;
|
||||
--z-toast: 800;
|
||||
|
||||
/* ===========================================
|
||||
FOCUS & ACCESSIBILITY
|
||||
Never remove focus indicators
|
||||
=========================================== */
|
||||
|
||||
--focus-ring-width: 2px;
|
||||
--focus-ring-offset: 2px;
|
||||
--focus-ring-color: var(--ring);
|
||||
|
||||
/* ===========================================
|
||||
ELEVATION & SHADOWS
|
||||
Subtle depth, prefer borders for emphasis
|
||||
=========================================== */
|
||||
|
||||
--shadow-none: none;
|
||||
--shadow-xs: 0 1px 2px 0 hsl(var(--foreground) / 0.05);
|
||||
--shadow-sm: 0 1px 3px 0 hsl(var(--foreground) / 0.08), 0 1px 2px -1px hsl(var(--foreground) / 0.08);
|
||||
--shadow-md: 0 4px 6px -1px hsl(var(--foreground) / 0.08), 0 2px 4px -2px hsl(var(--foreground) / 0.08);
|
||||
--shadow-lg: 0 10px 15px -3px hsl(var(--foreground) / 0.08), 0 4px 6px -4px hsl(var(--foreground) / 0.08);
|
||||
--shadow-xl: 0 20px 25px -5px hsl(var(--foreground) / 0.08), 0 8px 10px -6px hsl(var(--foreground) / 0.08);
|
||||
--shadow-2xl: 0 25px 50px -12px hsl(var(--foreground) / 0.15);
|
||||
--shadow-inner: inset 0 2px 4px 0 hsl(var(--foreground) / 0.05);
|
||||
|
||||
/* ===========================================
|
||||
RADIUS SCALE
|
||||
Sharp corners by default (brutalist)
|
||||
Explicit tokens for rare roundness
|
||||
=========================================== */
|
||||
|
||||
--radius-none: 0;
|
||||
--radius-xs: 2px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-2xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ===========================================
|
||||
BORDER WIDTH SCALE
|
||||
=========================================== */
|
||||
|
||||
--border-hairline: 0.5px;
|
||||
--border-thin: 1px;
|
||||
--border-medium: 2px;
|
||||
--border-thick: 4px;
|
||||
--border-heavy: 6px;
|
||||
--border-brutalist: 8px;
|
||||
|
||||
/* ===========================================
|
||||
OPACITY SCALE
|
||||
For disabled, subtle, overlay states
|
||||
=========================================== */
|
||||
|
||||
--opacity-0: 0;
|
||||
--opacity-disabled: 0.5;
|
||||
--opacity-subtle: 0.6;
|
||||
--opacity-muted: 0.4;
|
||||
--opacity-overlay: 0.8;
|
||||
--opacity-overlay-heavy: 0.9;
|
||||
--opacity-hover: 0.9;
|
||||
--opacity-full: 1;
|
||||
|
||||
/* ===========================================
|
||||
ADDITIONAL Z-INDEX LAYERS
|
||||
Extended from base scale
|
||||
=========================================== */
|
||||
|
||||
--z-drawer: 350;
|
||||
--z-command-palette: 900;
|
||||
--z-notification: 850;
|
||||
}
|
||||
|
||||
/* Dark mode (opt-in) - Vercel/Cursor/Perplexity aesthetic */
|
||||
/* Dark mode (opt-in) - Warm dark with copper accent */
|
||||
.dark {
|
||||
/* Backgrounds & Surfaces */
|
||||
--background: 210 10% 5%; /* #0d0d0f - Almost black */
|
||||
--surface: 210 10% 8%; /* #14141a - Slightly lighter */
|
||||
--surface-secondary: 210 10% 10%; /* Secondary surfaces */
|
||||
--surface-elevated: 210 10% 12%; /* #1c1c24 - Cards, modals */
|
||||
--surface-overlay: 210 10% 16%; /* #25252f - Overlays, popovers */
|
||||
/* ===========================================
|
||||
DARK MODE - Warm charcoal with copper
|
||||
=========================================== */
|
||||
|
||||
/* 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 */
|
||||
/* Backgrounds - Layered dark surfaces */
|
||||
--background: 25 10% 6%; /* #0F0E0D - Warm dark base */
|
||||
--surface: 25 10% 9%; /* #171514 - Cards, panels */
|
||||
--surface-2: 25 10% 12%; /* #1F1D1B - Elevated */
|
||||
--surface-3: 25 10% 16%; /* #2A2725 - Highest elevation */
|
||||
--surface-secondary: 25 10% 10%;
|
||||
--surface-elevated: 25 10% 12%;
|
||||
--surface-overlay: 25 10% 16%;
|
||||
|
||||
/* 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 */
|
||||
/* Text - Clear hierarchy in dark */
|
||||
--foreground: 30 10% 90%; /* #E8E5E2 - Primary text */
|
||||
--foreground-secondary: 25 6% 60%; /* #9A9592 - Secondary */
|
||||
--foreground-tertiary: 25 5% 45%; /* #757270 - Tertiary */
|
||||
--foreground-muted: 25 4% 35%; /* #5A5856 - Muted */
|
||||
|
||||
/* 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 */
|
||||
/* Borders - Subtle in dark */
|
||||
--border: 25 8% 20%; /* #363230 - Default */
|
||||
--border-strong: 25 8% 35%; /* #5C5854 - Emphasized */
|
||||
--border-subtle: 25 6% 14%; /* #252321 - Subtle */
|
||||
|
||||
/* Status Colors - Modern Editorial (Dark Mode) */
|
||||
--success: 152 57% 50%; /* Refined emerald - slightly brighter for dark */
|
||||
--success-foreground: 0 0% 100%; /* White text */
|
||||
--error: 0 65% 58%; /* Sophisticated red - brighter for dark */
|
||||
--error-foreground: 0 0% 100%; /* White text */
|
||||
--warning: 36 100% 55%; /* Warm amber - brighter for dark */
|
||||
--warning-foreground: 0 0% 100%; /* White text */
|
||||
--info: 210 100% 60%; /* Cool blue - brighter for dark */
|
||||
--info-foreground: 0 0% 100%; /* White text */
|
||||
/* Primary - Copper, brighter for dark mode */
|
||||
--primary: 22 57% 50%; /* #C96A38 - Brighter copper */
|
||||
--primary-hover: 22 57% 55%; /* #D47942 - Hover */
|
||||
--primary-active: 22 57% 45%; /* #B55E2E - Active */
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Destructive (legacy support) */
|
||||
--destructive: 0 65% 58%;
|
||||
/* Accent - Copper variations */
|
||||
--accent: 22 57% 50%;
|
||||
--accent-strong: 22 60% 55%;
|
||||
--accent-muted: 22 20% 15%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
|
||||
/* Secondary - Warm dark */
|
||||
--secondary: 25 10% 15%;
|
||||
--secondary-hover: 25 10% 20%;
|
||||
--secondary-foreground: 30 10% 90%;
|
||||
|
||||
/* Muted - Subtle dark */
|
||||
--muted: 25 8% 12%;
|
||||
--muted-foreground: 25 6% 55%;
|
||||
|
||||
/* Status Colors - Brighter for dark mode */
|
||||
--success: 145 50% 45%; /* Brighter green */
|
||||
--success-light: 145 30% 15%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
--warning: 38 80% 55%; /* Brighter amber */
|
||||
--warning-light: 38 40% 15%;
|
||||
--warning-foreground: 25 15% 10%;
|
||||
|
||||
--error: 0 60% 55%; /* Brighter red */
|
||||
--error-light: 0 40% 15%;
|
||||
--error-foreground: 0 0% 100%;
|
||||
|
||||
--info: 210 70% 55%; /* Brighter blue */
|
||||
--info-light: 210 40% 15%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: 0 60% 55%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Form Elements */
|
||||
--input: 210 10% 20%; /* Input border */
|
||||
--ring: 210 10% 90%; /* Focus ring */
|
||||
--ring-offset: 210 10% 5%; /* Focus ring offset */
|
||||
--input: 25 8% 20%;
|
||||
--input-focus: 22 57% 50%;
|
||||
--ring: 22 57% 50%;
|
||||
--ring-offset: 25 10% 6%;
|
||||
|
||||
/* Grid/Blueprint Pattern */
|
||||
--grid-color: 210 10% 15%; /* Grid line color */
|
||||
/* Grid */
|
||||
--grid-color: 25 8% 15%;
|
||||
|
||||
/* Card */
|
||||
--card: 210 10% 8%;
|
||||
--card-foreground: 210 10% 90%;
|
||||
--card: 25 10% 9%;
|
||||
--card-foreground: 30 10% 90%;
|
||||
|
||||
/* Brutalist Accent Colors - Modern Editorial */
|
||||
--brutalist-accent: 210 100% 60%; /* Cool sophisticated blue for dark mode */
|
||||
--brutalist-accent-hover: 210 100% 65%; /* Slightly brighter on hover */
|
||||
/* Brutalist Accents */
|
||||
--brutalist-accent: 22 57% 50%;
|
||||
--brutalist-accent-hover: 22 57% 55%;
|
||||
|
||||
/* Border Radii - SHARP CORNERS (0) */
|
||||
--radius-sm: 0;
|
||||
--radius-md: 0;
|
||||
--radius-lg: 0;
|
||||
--radius-xl: 0;
|
||||
--radius: 0;
|
||||
|
||||
/* ===========================================
|
||||
DARK MODE SHADOWS
|
||||
Darker, more prominent for visibility
|
||||
=========================================== */
|
||||
|
||||
--shadow-none: none;
|
||||
--shadow-xs: 0 1px 2px 0 hsl(0 0% 0% / 0.2);
|
||||
--shadow-sm: 0 1px 3px 0 hsl(0 0% 0% / 0.25), 0 1px 2px -1px hsl(0 0% 0% / 0.25);
|
||||
--shadow-md: 0 4px 6px -1px hsl(0 0% 0% / 0.25), 0 2px 4px -2px hsl(0 0% 0% / 0.25);
|
||||
--shadow-lg: 0 10px 15px -3px hsl(0 0% 0% / 0.25), 0 4px 6px -4px hsl(0 0% 0% / 0.25);
|
||||
--shadow-xl: 0 20px 25px -5px hsl(0 0% 0% / 0.3), 0 8px 10px -6px hsl(0 0% 0% / 0.3);
|
||||
--shadow-2xl: 0 25px 50px -12px hsl(0 0% 0% / 0.4);
|
||||
--shadow-inner: inset 0 2px 4px 0 hsl(0 0% 0% / 0.2);
|
||||
|
||||
/* Radius, border, opacity tokens inherit from light mode */
|
||||
}
|
||||
|
||||
/* Base element styles */
|
||||
|
|
@ -518,4 +750,96 @@
|
|||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
DENSITY MODES
|
||||
Use data-density attribute on container
|
||||
=========================================== */
|
||||
|
||||
/* Compact density */
|
||||
[data-density="compact"] {
|
||||
--density-row-height: 36px;
|
||||
--density-cell-padding-x: 8px;
|
||||
--density-cell-padding-y: 6px;
|
||||
--density-icon-size: 16px;
|
||||
--density-font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Comfortable density (default) */
|
||||
[data-density="comfortable"] {
|
||||
--density-row-height: 48px;
|
||||
--density-cell-padding-x: 16px;
|
||||
--density-cell-padding-y: 12px;
|
||||
--density-icon-size: 20px;
|
||||
--density-font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Spacious density */
|
||||
[data-density="spacious"] {
|
||||
--density-row-height: 64px;
|
||||
--density-cell-padding-x: 24px;
|
||||
--density-cell-padding-y: 20px;
|
||||
--density-icon-size: 24px;
|
||||
--density-font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Density utility classes */
|
||||
.density-row {
|
||||
height: var(--density-row-height);
|
||||
font-size: var(--density-font-size);
|
||||
}
|
||||
|
||||
.density-cell {
|
||||
padding: var(--density-cell-padding-y) var(--density-cell-padding-x);
|
||||
font-size: var(--density-font-size);
|
||||
}
|
||||
|
||||
.density-icon {
|
||||
width: var(--density-icon-size);
|
||||
height: var(--density-icon-size);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
PROSE WIDTHS FOR READABILITY
|
||||
=========================================== */
|
||||
|
||||
.prose-width {
|
||||
max-width: var(--prose-width);
|
||||
}
|
||||
|
||||
.prose-width-narrow {
|
||||
max-width: var(--prose-width-narrow);
|
||||
}
|
||||
|
||||
.prose-width-wide {
|
||||
max-width: var(--prose-width-wide);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
MOTION UTILITIES
|
||||
Using CSS custom properties
|
||||
=========================================== */
|
||||
|
||||
.motion-fast {
|
||||
transition-duration: var(--motion-fast);
|
||||
transition-timing-function: var(--easing-standard);
|
||||
}
|
||||
|
||||
.motion-base {
|
||||
transition-duration: var(--motion-base);
|
||||
transition-timing-function: var(--easing-standard);
|
||||
}
|
||||
|
||||
.motion-slow {
|
||||
transition-duration: var(--motion-slow);
|
||||
transition-timing-function: var(--easing-standard);
|
||||
}
|
||||
|
||||
.motion-linear {
|
||||
transition-timing-function: var(--easing-linear);
|
||||
}
|
||||
|
||||
.motion-spring {
|
||||
transition-timing-function: var(--easing-spring);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
153
apps/web/src/app/style-guide/page.tsx
Normal file
153
apps/web/src/app/style-guide/page.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import {
|
||||
FieldsetSection,
|
||||
NavItem,
|
||||
SectionA11yChecklists,
|
||||
SectionAccessibility,
|
||||
SectionColors,
|
||||
SectionComponentAPIs,
|
||||
SectionComponents,
|
||||
SectionContent,
|
||||
SectionContentGuidelines,
|
||||
SectionDataViz,
|
||||
SectionIconSystem,
|
||||
SectionIcons,
|
||||
SectionLayout,
|
||||
SectionMotion,
|
||||
SectionPatternFeedback,
|
||||
SectionPatternForms,
|
||||
SectionPatternNavigation,
|
||||
SectionPatternSearch,
|
||||
SectionPatternTables,
|
||||
SectionPrinciples,
|
||||
SectionSpacing,
|
||||
SectionTheming,
|
||||
SectionTypography,
|
||||
} from '~/components/style-guide';
|
||||
|
||||
export default function StyleGuidePage(): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [radioValue, setRadioValue] = useState('option1');
|
||||
const [counterValue, setCounterValue] = useState(1234);
|
||||
const [density, setDensity] = useState<'compact' | 'comfortable' | 'spacious'>('comfortable');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background">
|
||||
<AppHeader />
|
||||
|
||||
<main className="flex-1 py-24">
|
||||
<Container size="lg" padding="lg">
|
||||
{/* Hero Section */}
|
||||
<div className="mb-24">
|
||||
<h1 className="font-mono text-4xl md:text-5xl lg:text-6xl font-semibold mb-6 text-foreground lowercase tracking-tight">
|
||||
tpmjs design system
|
||||
</h1>
|
||||
<p className="font-sans text-lg text-foreground-secondary max-w-2xl leading-relaxed mb-8">
|
||||
The comprehensive design system for TPMJS. This guide covers design principles,
|
||||
foundations, components, and governance rules that scale across teams, AI agents,
|
||||
and external contributors.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Badge variant="default">v2.0</Badge>
|
||||
<Badge variant="outline">wcag aa</Badge>
|
||||
<Badge variant="outline">dark mode</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table of Contents */}
|
||||
<FieldsetSection title="table of contents" id="toc">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div className="space-y-2">
|
||||
<p className="font-mono text-xs text-foreground-tertiary uppercase tracking-wide mb-3">foundations</p>
|
||||
<NavItem href="#principles">1. design principles</NavItem>
|
||||
<NavItem href="#colors">2. color system</NavItem>
|
||||
<NavItem href="#typography">3. typography</NavItem>
|
||||
<NavItem href="#spacing">4. spacing</NavItem>
|
||||
<NavItem href="#motion">5. motion</NavItem>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="font-mono text-xs text-foreground-tertiary uppercase tracking-wide mb-3">systems</p>
|
||||
<NavItem href="#accessibility">6. accessibility</NavItem>
|
||||
<NavItem href="#layout">7. layout & responsiveness</NavItem>
|
||||
<NavItem href="#content">8. content guidelines</NavItem>
|
||||
<NavItem href="#data-viz">9. data visualization</NavItem>
|
||||
<NavItem href="#icons">10. iconography</NavItem>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="font-mono text-xs text-foreground-tertiary uppercase tracking-wide mb-3">implementation</p>
|
||||
<NavItem href="#theming">11. theming</NavItem>
|
||||
<NavItem href="#components">12. components</NavItem>
|
||||
<NavItem href="#api">13. component apis</NavItem>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="font-mono text-xs text-foreground-tertiary uppercase tracking-wide mb-3">patterns</p>
|
||||
<NavItem href="#nav-patterns">14. navigation</NavItem>
|
||||
<NavItem href="#form-patterns">15. forms</NavItem>
|
||||
<NavItem href="#feedback-patterns">16. feedback</NavItem>
|
||||
<NavItem href="#table-patterns">17. tables</NavItem>
|
||||
<NavItem href="#search-patterns">18. search & filtering</NavItem>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="font-mono text-xs text-foreground-tertiary uppercase tracking-wide mb-3">governance</p>
|
||||
<NavItem href="#a11y-checklists">19. a11y checklists</NavItem>
|
||||
<NavItem href="#content-guidelines">20. content & writing</NavItem>
|
||||
<NavItem href="#icon-system">21. icon system</NavItem>
|
||||
</div>
|
||||
</div>
|
||||
</FieldsetSection>
|
||||
|
||||
{/* Foundation Sections */}
|
||||
<SectionPrinciples />
|
||||
<SectionColors />
|
||||
<SectionTypography />
|
||||
<SectionSpacing />
|
||||
<SectionMotion />
|
||||
|
||||
{/* Systems Sections */}
|
||||
<SectionAccessibility />
|
||||
<SectionLayout density={density} onDensityChange={setDensity} />
|
||||
<SectionContent />
|
||||
<SectionDataViz counterValue={counterValue} onCounterChange={setCounterValue} />
|
||||
<SectionIcons />
|
||||
|
||||
{/* Implementation Sections */}
|
||||
<SectionTheming />
|
||||
<SectionComponents
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
radioValue={radioValue}
|
||||
onRadioChange={setRadioValue}
|
||||
/>
|
||||
<SectionComponentAPIs />
|
||||
|
||||
{/* Pattern Library Sections */}
|
||||
<SectionPatternNavigation />
|
||||
<SectionPatternForms />
|
||||
<SectionPatternFeedback />
|
||||
<SectionPatternTables />
|
||||
<SectionPatternSearch />
|
||||
|
||||
{/* Governance Sections */}
|
||||
<SectionA11yChecklists />
|
||||
<SectionContentGuidelines />
|
||||
<SectionIconSystem />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center py-12 border-t border-dashed border-border mt-16">
|
||||
<p className="font-mono text-sm text-foreground-secondary">
|
||||
tpmjs design system v2.0
|
||||
</p>
|
||||
<p className="font-sans text-xs text-foreground-tertiary mt-2">
|
||||
built with @tpmjs/ui • inspired by turbopuffer.com
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
465
apps/web/src/components/style-guide/SectionA11yChecklists.tsx
Normal file
465
apps/web/src/components/style-guide/SectionA11yChecklists.tsx
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@tpmjs/ui/Table/Table';
|
||||
import { FieldsetSection, SubSection } from './shared';
|
||||
|
||||
type A11yRequirement = {
|
||||
component: string;
|
||||
ariaPattern: string;
|
||||
focusTrap: boolean;
|
||||
keyboard: string[];
|
||||
reducedMotion: string;
|
||||
};
|
||||
|
||||
const overlayComponents: A11yRequirement[] = [
|
||||
{
|
||||
component: 'Modal',
|
||||
ariaPattern: 'dialog',
|
||||
focusTrap: true,
|
||||
keyboard: ['Esc (close)', 'Tab (cycle focus)'],
|
||||
reducedMotion: 'Instant open/close',
|
||||
},
|
||||
{
|
||||
component: 'Drawer',
|
||||
ariaPattern: 'dialog',
|
||||
focusTrap: true,
|
||||
keyboard: ['Esc (close)', 'Tab (cycle focus)'],
|
||||
reducedMotion: 'No slide animation',
|
||||
},
|
||||
{
|
||||
component: 'Popover',
|
||||
ariaPattern: 'dialog',
|
||||
focusTrap: false,
|
||||
keyboard: ['Esc (close)', 'Tab (move to next)'],
|
||||
reducedMotion: 'Instant show/hide',
|
||||
},
|
||||
{
|
||||
component: 'Tooltip',
|
||||
ariaPattern: 'tooltip',
|
||||
focusTrap: false,
|
||||
keyboard: ['Focus trigger (show)', 'Blur (hide)'],
|
||||
reducedMotion: 'Instant show/hide',
|
||||
},
|
||||
{
|
||||
component: 'DropdownMenu',
|
||||
ariaPattern: 'menu',
|
||||
focusTrap: false,
|
||||
keyboard: ['Enter/Space (select)', 'Arrow keys (navigate)', 'Esc (close)'],
|
||||
reducedMotion: 'Instant show/hide',
|
||||
},
|
||||
{
|
||||
component: 'Toast',
|
||||
ariaPattern: 'alert / status',
|
||||
focusTrap: false,
|
||||
keyboard: ['Focus action button', 'Enter (action)'],
|
||||
reducedMotion: 'No slide animation',
|
||||
},
|
||||
];
|
||||
|
||||
const formComponents: A11yRequirement[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
ariaPattern: 'textbox',
|
||||
focusTrap: false,
|
||||
keyboard: ['Tab (focus)', 'Type (input)'],
|
||||
reducedMotion: 'N/A',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
ariaPattern: 'listbox',
|
||||
focusTrap: false,
|
||||
keyboard: ['Enter/Space (open)', 'Arrow keys (navigate)', 'Esc (close)'],
|
||||
reducedMotion: 'Instant show/hide',
|
||||
},
|
||||
{
|
||||
component: 'Checkbox',
|
||||
ariaPattern: 'checkbox',
|
||||
focusTrap: false,
|
||||
keyboard: ['Space (toggle)', 'Tab (focus)'],
|
||||
reducedMotion: 'N/A',
|
||||
},
|
||||
{
|
||||
component: 'Radio',
|
||||
ariaPattern: 'radiogroup',
|
||||
focusTrap: false,
|
||||
keyboard: ['Arrow keys (select)', 'Tab (focus)'],
|
||||
reducedMotion: 'N/A',
|
||||
},
|
||||
{
|
||||
component: 'Switch',
|
||||
ariaPattern: 'switch',
|
||||
focusTrap: false,
|
||||
keyboard: ['Space (toggle)', 'Tab (focus)'],
|
||||
reducedMotion: 'Instant toggle',
|
||||
},
|
||||
{
|
||||
component: 'Slider',
|
||||
ariaPattern: 'slider',
|
||||
focusTrap: false,
|
||||
keyboard: ['Arrow keys (adjust)', 'Home/End (min/max)'],
|
||||
reducedMotion: 'N/A',
|
||||
},
|
||||
];
|
||||
|
||||
const navigationComponents: A11yRequirement[] = [
|
||||
{
|
||||
component: 'Tabs',
|
||||
ariaPattern: 'tablist',
|
||||
focusTrap: false,
|
||||
keyboard: ['Arrow keys (navigate)', 'Enter/Space (select)', 'Home/End (first/last)'],
|
||||
reducedMotion: 'N/A',
|
||||
},
|
||||
{
|
||||
component: 'Breadcrumbs',
|
||||
ariaPattern: 'navigation',
|
||||
focusTrap: false,
|
||||
keyboard: ['Tab (focus links)', 'Enter (activate)'],
|
||||
reducedMotion: 'N/A',
|
||||
},
|
||||
{
|
||||
component: 'Pagination',
|
||||
ariaPattern: 'navigation',
|
||||
focusTrap: false,
|
||||
keyboard: ['Tab (focus)', 'Enter (activate)'],
|
||||
reducedMotion: 'N/A',
|
||||
},
|
||||
{
|
||||
component: 'Accordion',
|
||||
ariaPattern: 'region',
|
||||
focusTrap: false,
|
||||
keyboard: ['Enter/Space (toggle)', 'Tab (focus)'],
|
||||
reducedMotion: 'Instant expand/collapse',
|
||||
},
|
||||
];
|
||||
|
||||
function A11yTable({ components, title }: { components: A11yRequirement[]; title: string }) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h4 className="font-mono text-sm font-medium mb-4 pb-2 border-b border-dashed border-border">
|
||||
{title}
|
||||
</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>component</TableHead>
|
||||
<TableHead>aria pattern</TableHead>
|
||||
<TableHead>focus trap</TableHead>
|
||||
<TableHead>keyboard</TableHead>
|
||||
<TableHead>reduced motion</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{components.map((comp) => (
|
||||
<TableRow key={comp.component}>
|
||||
<TableCell className="font-mono text-sm">{comp.component}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-surface-2 px-2 py-0.5">{comp.ariaPattern}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{comp.focusTrap ? (
|
||||
<Badge variant="success" size="sm">yes</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" size="sm">no</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
{comp.keyboard.map((k, i) => (
|
||||
<div key={i} className="text-xs text-foreground-secondary">{k}</div>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-foreground-secondary">
|
||||
{comp.reducedMotion}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionA11yChecklists(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="19. accessibility checklists" id="a11y-checklists">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Component-level accessibility requirements and implementation guides.
|
||||
All components follow WCAG 2.1 AA standards.
|
||||
</p>
|
||||
|
||||
<SubSection title="aria patterns & keyboard">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-6">
|
||||
Each component has specific ARIA patterns and keyboard interactions that must be implemented.
|
||||
</p>
|
||||
|
||||
<A11yTable components={overlayComponents} title="overlay components" />
|
||||
<A11yTable components={formComponents} title="form components" />
|
||||
<A11yTable components={navigationComponents} title="navigation components" />
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="focus management">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Proper focus management is critical for keyboard and screen reader users.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4 flex items-center gap-2">
|
||||
<Icon icon="check" size="sm" className="text-success" />
|
||||
focus trap required
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>Modal dialogs - trap focus inside</li>
|
||||
<li>Drawer sheets - trap until closed</li>
|
||||
<li>Full-screen overlays</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4 flex items-center gap-2">
|
||||
<Icon icon="arrowRight" size="sm" className="text-accent" />
|
||||
focus restoration
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>Return focus to trigger on close</li>
|
||||
<li>Save and restore focus position</li>
|
||||
<li>Skip links for long content</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-3">implementation pattern</h4>
|
||||
<pre className="text-xs font-mono text-foreground-secondary overflow-x-auto">
|
||||
{`// Focus trap implementation
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Save trigger reference before opening
|
||||
const handleOpen = () => {
|
||||
triggerRef.current = document.activeElement as HTMLButtonElement;
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
// Restore focus on close
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};`}
|
||||
</pre>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="screen reader announcements">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Use live regions to announce dynamic content changes.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-3">aria-live regions</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-3 bg-surface-2">
|
||||
<code className="text-xs">aria-live="polite"</code>
|
||||
<p className="text-xs text-foreground-secondary mt-2">
|
||||
Toast notifications, status updates
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-surface-2">
|
||||
<code className="text-xs">aria-live="assertive"</code>
|
||||
<p className="text-xs text-foreground-secondary mt-2">
|
||||
Error messages, critical alerts
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-surface-2">
|
||||
<code className="text-xs">role="status"</code>
|
||||
<p className="text-xs text-foreground-secondary mt-2">
|
||||
Loading states, progress updates
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-3">component announcements</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>event</TableHead>
|
||||
<TableHead>announcement</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">Toast shown</TableCell>
|
||||
<TableCell className="text-sm text-foreground-secondary">"[message content]"</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">Modal opened</TableCell>
|
||||
<TableCell className="text-sm text-foreground-secondary">"[dialog title], dialog"</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">Form error</TableCell>
|
||||
<TableCell className="text-sm text-foreground-secondary">"Error: [field name], [error message]"</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">Loading complete</TableCell>
|
||||
<TableCell className="text-sm text-foreground-secondary">"Loading complete, [N] results"</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="color contrast requirements">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
All text must meet WCAG AA contrast requirements (4.5:1 for normal text, 3:1 for large text).
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-background border border-border">
|
||||
<span className="text-foreground">foreground on background</span>
|
||||
<Badge variant="success" size="sm">9.2:1</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-surface border border-border">
|
||||
<span className="text-foreground-secondary">secondary on surface</span>
|
||||
<Badge variant="success" size="sm">5.8:1</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-surface border border-border">
|
||||
<span className="text-foreground-tertiary">tertiary on surface</span>
|
||||
<Badge variant="warning" size="sm">4.5:1</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-3">contrast verification</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>• Test all color combinations in both themes</li>
|
||||
<li>• Verify focus indicators (3:1 minimum)</li>
|
||||
<li>• Ensure error states meet requirements</li>
|
||||
<li>• Check interactive state contrast changes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="reduced motion support">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Respect the <code className="px-1 bg-surface-2 text-xs">prefers-reduced-motion</code> media query
|
||||
for users who experience motion sickness or vestibular disorders.
|
||||
</p>
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-3">implementation</h4>
|
||||
<pre className="text-xs font-mono text-foreground-secondary overflow-x-auto">
|
||||
{`// CSS approach
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
// React hook approach
|
||||
import { useReducedMotion } from '@tpmjs/ui/system/hooks/useReducedMotion';
|
||||
|
||||
function AnimatedComponent() {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={prefersReducedMotion ? 'instant' : 'animated'}
|
||||
/>
|
||||
);
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-3 bg-success-light border border-success">
|
||||
<p className="font-mono text-sm text-success mb-1">always animate</p>
|
||||
<p className="text-xs text-foreground-secondary">Progress bars, loading spinners</p>
|
||||
</div>
|
||||
<div className="p-3 bg-warning-light border border-warning">
|
||||
<p className="font-mono text-sm text-warning mb-1">respect preference</p>
|
||||
<p className="text-xs text-foreground-secondary">Page transitions, modal animations</p>
|
||||
</div>
|
||||
<div className="p-3 bg-error-light border border-error">
|
||||
<p className="font-mono text-sm text-error mb-1">never animate</p>
|
||||
<p className="text-xs text-foreground-secondary">Auto-playing video, parallax</p>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="testing checklist">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Before shipping any component, verify accessibility with these tests.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<Icon icon="terminal" size="sm" />
|
||||
keyboard testing
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li className="flex items-center gap-2">
|
||||
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
|
||||
Tab through all interactive elements
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
|
||||
Verify focus is visible at all times
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
|
||||
Test Enter/Space activation
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
|
||||
Verify Escape closes overlays
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
|
||||
Test arrow key navigation
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<Icon icon="search" size="sm" />
|
||||
screen reader testing
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li className="flex items-center gap-2">
|
||||
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
|
||||
Test with VoiceOver (macOS/iOS)
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
|
||||
Test with NVDA (Windows)
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
|
||||
Verify all content is announced
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
|
||||
Check landmark navigation
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
|
||||
Test live region updates
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
99
apps/web/src/components/style-guide/SectionAccessibility.tsx
Normal file
99
apps/web/src/components/style-guide/SectionAccessibility.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { DoDontCard, FieldsetSection, SubSection } from './shared';
|
||||
|
||||
export function SectionAccessibility(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="6. accessibility" id="accessibility">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
TPMJS targets WCAG 2.1 AA compliance. Accessibility is not optional—it's
|
||||
a core requirement for every component.
|
||||
</p>
|
||||
|
||||
<SubSection title="standards">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-surface p-6 border border-dashed border-border text-center">
|
||||
<p className="font-mono text-4xl font-bold text-accent mb-2">AA</p>
|
||||
<p className="font-mono text-sm text-foreground-secondary">WCAG 2.1 target</p>
|
||||
</div>
|
||||
<div className="bg-surface p-6 border border-dashed border-border text-center">
|
||||
<p className="font-mono text-4xl font-bold text-foreground mb-2">4.5:1</p>
|
||||
<p className="font-mono text-sm text-foreground-secondary">min contrast (text)</p>
|
||||
</div>
|
||||
<div className="bg-surface p-6 border border-dashed border-border text-center">
|
||||
<p className="font-mono text-4xl font-bold text-foreground mb-2">3:1</p>
|
||||
<p className="font-mono text-sm text-foreground-secondary">min contrast (UI)</p>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="focus management">
|
||||
<div className="space-y-6">
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">focus ring design</h4>
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Focus rings use the copper accent color with 2px width and 2px offset.
|
||||
They are <strong>never removed</strong> from interactive elements.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||
tab to me
|
||||
</Button>
|
||||
<Input placeholder="or me" className="max-w-xs" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">keyboard navigation</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>• <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Tab</kbd> moves focus forward through interactive elements</li>
|
||||
<li>• <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Shift+Tab</kbd> moves focus backward</li>
|
||||
<li>• <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Enter</kbd> / <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Space</kbd> activates buttons and links</li>
|
||||
<li>• <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Esc</kbd> closes modals and dropdowns</li>
|
||||
<li>• Arrow keys navigate within components (tabs, radios, menus)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="color independence">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<DoDontCard type="do" title="Use icons + color for states">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<Icon icon="check" size="sm" />
|
||||
<span className="text-sm">Published</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-error">
|
||||
<Icon icon="x" size="sm" />
|
||||
<span className="text-sm">Failed</span>
|
||||
</div>
|
||||
</div>
|
||||
</DoDontCard>
|
||||
<DoDontCard type="dont" title="Don't rely on color alone">
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-sm text-success">Published</span>
|
||||
<span className="text-sm text-error">Failed</span>
|
||||
</div>
|
||||
</DoDontCard>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="screen reader support">
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<ul className="space-y-3 text-sm text-foreground-secondary font-sans">
|
||||
<li>• All interactive elements have accessible names (aria-label or visible text)</li>
|
||||
<li>• Images include alt text describing content</li>
|
||||
<li>• Form inputs are associated with labels</li>
|
||||
<li>• Error messages are announced via aria-live regions</li>
|
||||
<li>• Loading states communicate progress to screen readers</li>
|
||||
<li>• Decorative elements are hidden from assistive technology</li>
|
||||
</ul>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
178
apps/web/src/components/style-guide/SectionColors.tsx
Normal file
178
apps/web/src/components/style-guide/SectionColors.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@tpmjs/ui/Table/Table';
|
||||
import { ColorCard, DoDontCard, FieldsetSection, SubSection } from './shared';
|
||||
|
||||
export function SectionColors(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="2. color system" id="colors">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
A warm earthy palette with copper accent. Colors are designed for clarity,
|
||||
hierarchy, and accessibility.
|
||||
</p>
|
||||
|
||||
{/* Color Palette Display */}
|
||||
<SubSection title="backgrounds">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<ColorCard name="bg" color="bg-background" hex="#F5F3F0" desc="base background" />
|
||||
<ColorCard name="surface" color="bg-surface" hex="#FFFFFF" desc="cards, panels" />
|
||||
<ColorCard name="surface-2" color="bg-surface-2" hex="#FAF8F6" desc="elevated surfaces" />
|
||||
<ColorCard name="surface-3" color="bg-surface-3" hex="#F0EDEA" desc="highest elevation" />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="borders">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<ColorCard name="border" color="bg-border" hex="#CEC9C3" desc="default borders" />
|
||||
<ColorCard name="border-strong" color="bg-border-strong" hex="#857F77" desc="emphasized borders" />
|
||||
<ColorCard name="border-subtle" color="bg-border-subtle" hex="#E3E0DC" desc="subtle borders" />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="text">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<ColorCard name="text" color="bg-foreground" hex="#1A1715" desc="primary text" textLight />
|
||||
<ColorCard name="text-secondary" color="bg-foreground-secondary" hex="#5C564F" desc="secondary text" textLight />
|
||||
<ColorCard name="text-tertiary" color="bg-foreground-tertiary" hex="#817B74" desc="tertiary text" textLight />
|
||||
<ColorCard name="text-muted" color="bg-foreground-muted" hex="#9A958F" desc="muted text" textLight />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="accent (copper)">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<ColorCard name="accent" color="bg-accent" hex="#A6592D" desc="brand accent" textLight />
|
||||
<ColorCard name="accent-strong" color="bg-accent-strong" hex="#8F4722" desc="hover accent" textLight />
|
||||
<ColorCard name="accent-muted" color="bg-accent-muted" hex="#EDE5DF" desc="soft tint" />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="status">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<ColorCard name="success" color="bg-success" hex="#327D52" desc="success states" textLight />
|
||||
<ColorCard name="warning" color="bg-warning" hex="#D9A020" desc="warning states" />
|
||||
<ColorCard name="error" color="bg-error" hex="#C44545" desc="error states" textLight />
|
||||
<ColorCard name="info" color="bg-info" hex="#3380CC" desc="info states" textLight />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="status (light backgrounds)">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<ColorCard name="success-light" color="bg-success-light" hex="#E5F3EC" desc="success bg" />
|
||||
<ColorCard name="warning-light" color="bg-warning-light" hex="#F8F2E0" desc="warning bg" />
|
||||
<ColorCard name="error-light" color="bg-error-light" hex="#F9EDED" desc="error bg" />
|
||||
<ColorCard name="info-light" color="bg-info-light" hex="#EDF4FB" desc="info bg" />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
{/* Color Usage Rules */}
|
||||
<SubSection title="color usage rules">
|
||||
<div className="space-y-6 mb-8">
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">when to use copper vs neutral</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>• <strong>Copper:</strong> Primary actions, active states, links, key metrics</li>
|
||||
<li>• <strong>Neutral:</strong> Secondary actions, borders, backgrounds, body text</li>
|
||||
<li>• <strong>Rule:</strong> Copper should be max ~10% of visible screen area</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">semantic color priority</h4>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant="error">error</Badge>
|
||||
<span className="text-foreground-tertiary">{'>'}</span>
|
||||
<Badge variant="warning">warning</Badge>
|
||||
<span className="text-foreground-tertiary">{'>'}</span>
|
||||
<Badge variant="success">success</Badge>
|
||||
<span className="text-foreground-tertiary">{'>'}</span>
|
||||
<Badge variant="info">info</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-tertiary mt-3 font-sans">
|
||||
When multiple states apply, show the highest priority color.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Do/Don't Examples */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<DoDontCard type="do" title="Use copper for primary CTAs">
|
||||
<div className="flex gap-3">
|
||||
<Button>publish tool</Button>
|
||||
<Button variant="secondary">cancel</Button>
|
||||
</div>
|
||||
</DoDontCard>
|
||||
<DoDontCard type="dont" title="Don't use copper for everything">
|
||||
<div className="flex gap-3">
|
||||
<Button>publish</Button>
|
||||
<Button>cancel</Button>
|
||||
<Button>back</Button>
|
||||
</div>
|
||||
</DoDontCard>
|
||||
<DoDontCard type="do" title="Use status colors with meaning">
|
||||
<div className="flex gap-3 items-center">
|
||||
<Badge variant="success">published</Badge>
|
||||
<Badge variant="warning">pending</Badge>
|
||||
<Badge variant="error">failed</Badge>
|
||||
</div>
|
||||
</DoDontCard>
|
||||
<DoDontCard type="dont" title="Don't use colors decoratively">
|
||||
<div className="flex gap-3 items-center">
|
||||
<Badge variant="success">tools</Badge>
|
||||
<Badge variant="warning">agents</Badge>
|
||||
<Badge variant="error">users</Badge>
|
||||
</div>
|
||||
</DoDontCard>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="contrast ratios (wcag aa)">
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="bordered">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>combination</TableHead>
|
||||
<TableHead>ratio</TableHead>
|
||||
<TableHead>normal text</TableHead>
|
||||
<TableHead>large text</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono">foreground / background</TableCell>
|
||||
<TableCell className="font-mono">12.5:1</TableCell>
|
||||
<TableCell><Badge variant="success" size="sm">pass</Badge></TableCell>
|
||||
<TableCell><Badge variant="success" size="sm">pass</Badge></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono">accent / background</TableCell>
|
||||
<TableCell className="font-mono">5.2:1</TableCell>
|
||||
<TableCell><Badge variant="success" size="sm">pass</Badge></TableCell>
|
||||
<TableCell><Badge variant="success" size="sm">pass</Badge></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono">foreground-secondary / background</TableCell>
|
||||
<TableCell className="font-mono">5.8:1</TableCell>
|
||||
<TableCell><Badge variant="success" size="sm">pass</Badge></TableCell>
|
||||
<TableCell><Badge variant="success" size="sm">pass</Badge></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono">foreground-tertiary / background</TableCell>
|
||||
<TableCell className="font-mono">3.9:1</TableCell>
|
||||
<TableCell><Badge variant="warning" size="sm">large only</Badge></TableCell>
|
||||
<TableCell><Badge variant="success" size="sm">pass</Badge></TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
178
apps/web/src/components/style-guide/SectionComponentAPIs.tsx
Normal file
178
apps/web/src/components/style-guide/SectionComponentAPIs.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
'use client';
|
||||
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@tpmjs/ui/Table/Table';
|
||||
import { FieldsetSection, SubSection } from './shared';
|
||||
|
||||
export function SectionComponentAPIs(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="13. component apis" id="api">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Complete API documentation for each component with props, types,
|
||||
and usage examples.
|
||||
</p>
|
||||
|
||||
<SubSection title="button api">
|
||||
<div className="space-y-6">
|
||||
<Table variant="bordered">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>prop</TableHead>
|
||||
<TableHead>type</TableHead>
|
||||
<TableHead>default</TableHead>
|
||||
<TableHead>description</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono">variant</TableCell>
|
||||
<TableCell className="font-mono text-xs">'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'</TableCell>
|
||||
<TableCell className="font-mono">'default'</TableCell>
|
||||
<TableCell>visual style</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono">size</TableCell>
|
||||
<TableCell className="font-mono text-xs">'sm' | 'md' | 'lg' | 'icon'</TableCell>
|
||||
<TableCell className="font-mono">'md'</TableCell>
|
||||
<TableCell>button size</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono">loading</TableCell>
|
||||
<TableCell className="font-mono text-xs">boolean</TableCell>
|
||||
<TableCell className="font-mono">false</TableCell>
|
||||
<TableCell>show spinner</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono">disabled</TableCell>
|
||||
<TableCell className="font-mono text-xs">boolean</TableCell>
|
||||
<TableCell className="font-mono">false</TableCell>
|
||||
<TableCell>disable interaction</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<CodeBlock
|
||||
code={`// Basic usage
|
||||
<Button>click me</Button>
|
||||
|
||||
// With variant and size
|
||||
<Button variant="destructive" size="lg">
|
||||
delete tool
|
||||
</Button>
|
||||
|
||||
// Loading state
|
||||
<Button loading>publishing...</Button>
|
||||
|
||||
// With icon
|
||||
<Button>
|
||||
<Icon icon="plus" size="sm" className="mr-2" />
|
||||
add tool
|
||||
</Button>`}
|
||||
language="tsx"
|
||||
/>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="input api">
|
||||
<div className="space-y-6">
|
||||
<Table variant="bordered">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>prop</TableHead>
|
||||
<TableHead>type</TableHead>
|
||||
<TableHead>default</TableHead>
|
||||
<TableHead>description</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono">state</TableCell>
|
||||
<TableCell className="font-mono text-xs">'default' | 'error' | 'success'</TableCell>
|
||||
<TableCell className="font-mono">'default'</TableCell>
|
||||
<TableCell>visual state</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono">size</TableCell>
|
||||
<TableCell className="font-mono text-xs">'sm' | 'md' | 'lg'</TableCell>
|
||||
<TableCell className="font-mono">'md'</TableCell>
|
||||
<TableCell>input size</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<CodeBlock
|
||||
code={`// Basic usage
|
||||
<Input placeholder="enter text..." />
|
||||
|
||||
// With error state
|
||||
<Input
|
||||
state="error"
|
||||
placeholder="invalid input"
|
||||
/>
|
||||
|
||||
// With FormField wrapper
|
||||
<FormField
|
||||
label="Email"
|
||||
error="Invalid email address"
|
||||
>
|
||||
<Input state="error" />
|
||||
</FormField>`}
|
||||
language="tsx"
|
||||
/>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="anti-patterns">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-error-light p-6 border border-error">
|
||||
<h4 className="font-mono text-sm font-medium mb-4 text-error">don't do this</h4>
|
||||
<CodeBlock
|
||||
code={`// ❌ Using inline styles
|
||||
<Button style={{ backgroundColor: 'red' }}>
|
||||
delete
|
||||
</Button>
|
||||
|
||||
// ❌ Nesting buttons
|
||||
<Button>
|
||||
<Button>nested</Button>
|
||||
</Button>
|
||||
|
||||
// ❌ Missing accessible name
|
||||
<Button size="icon">
|
||||
<Icon icon="plus" />
|
||||
</Button>`}
|
||||
language="tsx"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-success-light p-6 border border-success">
|
||||
<h4 className="font-mono text-sm font-medium mb-4 text-success">do this instead</h4>
|
||||
<CodeBlock
|
||||
code={`// ✅ Using variants
|
||||
<Button variant="destructive">
|
||||
delete
|
||||
</Button>
|
||||
|
||||
// ✅ Single button
|
||||
<Button>action</Button>
|
||||
|
||||
// ✅ With aria-label
|
||||
<Button size="icon" aria-label="Add item">
|
||||
<Icon icon="plus" />
|
||||
</Button>`}
|
||||
language="tsx"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
286
apps/web/src/components/style-guide/SectionComponents.tsx
Normal file
286
apps/web/src/components/style-guide/SectionComponents.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
'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 { Checkbox } from '@tpmjs/ui/Checkbox/Checkbox';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { FormField } from '@tpmjs/ui/FormField/FormField';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { Radio } from '@tpmjs/ui/Radio/Radio';
|
||||
import { RadioGroup } from '@tpmjs/ui/Radio/RadioGroup';
|
||||
import { Select } from '@tpmjs/ui/Select/Select';
|
||||
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||
import { Switch } from '@tpmjs/ui/Switch/Switch';
|
||||
import { Tabs } from '@tpmjs/ui/Tabs/Tabs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@tpmjs/ui/Table/Table';
|
||||
import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
|
||||
import { FieldsetSection, SubSection } from './shared';
|
||||
|
||||
interface SectionComponentsProps {
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
radioValue: string;
|
||||
onRadioChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SectionComponents({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
radioValue,
|
||||
onRadioChange
|
||||
}: SectionComponentsProps): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="12. components" id="components">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Complete component catalog with all variants and states.
|
||||
</p>
|
||||
|
||||
{/* Button */}
|
||||
<SubSection title="button">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mb-3">variants</p>
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<Button variant="default">default</Button>
|
||||
<Button variant="secondary">secondary</Button>
|
||||
<Button variant="destructive">destructive</Button>
|
||||
<Button variant="outline">outline</Button>
|
||||
<Button variant="outline-dotted">outline dotted</Button>
|
||||
<Button variant="blueprint">blueprint</Button>
|
||||
<Button variant="ghost">ghost</Button>
|
||||
<Button variant="link">link</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mb-3">sizes</p>
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<Button size="sm">small</Button>
|
||||
<Button size="md">medium</Button>
|
||||
<Button size="lg">large</Button>
|
||||
<Button size="icon"><Icon icon="plus" size="sm" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mb-3">states</p>
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<Button>normal</Button>
|
||||
<Button disabled>disabled</Button>
|
||||
<Button loading>loading</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
{/* Badge */}
|
||||
<SubSection title="badge">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mb-3">variants</p>
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<Badge variant="default">default</Badge>
|
||||
<Badge variant="secondary">secondary</Badge>
|
||||
<Badge variant="outline">outline</Badge>
|
||||
<Badge variant="success">success</Badge>
|
||||
<Badge variant="error">error</Badge>
|
||||
<Badge variant="warning">warning</Badge>
|
||||
<Badge variant="info">info</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mb-3">sizes</p>
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<Badge size="sm">small</Badge>
|
||||
<Badge size="md">medium</Badge>
|
||||
<Badge size="lg">large</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
{/* Card */}
|
||||
<SubSection title="card">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{(['default', 'elevated', 'outline', 'blueprint', 'featured', 'brutalist'] as const).map((variant) => (
|
||||
<Card key={variant} variant={variant}>
|
||||
<CardHeader>
|
||||
<CardTitle>{variant} card</CardTitle>
|
||||
<CardDescription>card variant example</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-foreground-secondary text-sm">card content goes here.</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button size="sm">action</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
{/* Form Elements */}
|
||||
<SubSection title="form elements">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-6">
|
||||
<FormField label="input">
|
||||
<Input placeholder="enter text..." />
|
||||
</FormField>
|
||||
<FormField label="textarea">
|
||||
<Textarea placeholder="enter text..." rows={3} />
|
||||
</FormField>
|
||||
<FormField label="select">
|
||||
<Select
|
||||
placeholder="select option..."
|
||||
options={[
|
||||
{ value: '1', label: 'Option 1' },
|
||||
{ value: '2', label: 'Option 2' },
|
||||
{ value: '3', label: 'Option 3' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mb-3">checkbox</p>
|
||||
<div className="flex gap-6">
|
||||
<Checkbox label="unchecked" />
|
||||
<Checkbox label="checked" checked onChange={() => {}} />
|
||||
<Checkbox label="disabled" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mb-3">switch</p>
|
||||
<div className="flex gap-6">
|
||||
<Switch label="off" />
|
||||
<Switch label="on" checked onChange={() => {}} />
|
||||
<Switch label="disabled" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mb-3">radio group</p>
|
||||
<RadioGroup
|
||||
name="demo-radio"
|
||||
value={radioValue}
|
||||
onChange={onRadioChange}
|
||||
orientation="horizontal"
|
||||
>
|
||||
<Radio value="option1" label="option 1" />
|
||||
<Radio value="option2" label="option 2" />
|
||||
<Radio value="option3" label="option 3" />
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
{/* Tabs */}
|
||||
<SubSection title="tabs">
|
||||
<div className="space-y-6">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'all', label: 'all items', count: 128 },
|
||||
{ id: 'active', label: 'active', count: 42 },
|
||||
{ id: 'archived', label: 'archived', count: 86 },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
variant="default"
|
||||
/>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
{/* Table */}
|
||||
<SubSection title="table">
|
||||
<Table>
|
||||
<TableCaption>example data table</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>name</TableHead>
|
||||
<TableHead>category</TableHead>
|
||||
<TableHead className="text-right">downloads</TableHead>
|
||||
<TableHead className="text-right">score</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono">@tpmjs/parser</TableCell>
|
||||
<TableCell>utility</TableCell>
|
||||
<TableCell className="text-right font-mono">125,432</TableCell>
|
||||
<TableCell className="text-right font-mono">0.92</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono">@tpmjs/validator</TableCell>
|
||||
<TableCell>validation</TableCell>
|
||||
<TableCell className="text-right font-mono">89,231</TableCell>
|
||||
<TableCell className="text-right font-mono">0.87</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono">@tpmjs/transform</TableCell>
|
||||
<TableCell>data</TableCell>
|
||||
<TableCell className="text-right font-mono">45,678</TableCell>
|
||||
<TableCell className="text-right font-mono">0.81</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SubSection>
|
||||
|
||||
{/* Spinner */}
|
||||
<SubSection title="spinner">
|
||||
<div className="flex flex-wrap gap-12 items-center">
|
||||
<div className="text-center">
|
||||
<Spinner size="xs" />
|
||||
<p className="font-mono text-xs text-foreground-secondary mt-4">xs</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Spinner size="sm" />
|
||||
<p className="font-mono text-xs text-foreground-secondary mt-4">sm</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Spinner size="md" />
|
||||
<p className="font-mono text-xs text-foreground-secondary mt-4">md</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Spinner size="lg" />
|
||||
<p className="font-mono text-xs text-foreground-secondary mt-4">lg</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Spinner size="xl" />
|
||||
<p className="font-mono text-xs text-foreground-secondary mt-4">xl</p>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
{/* Code Block */}
|
||||
<SubSection title="code block">
|
||||
<CodeBlock
|
||||
code={`import { Button } from '@tpmjs/ui/Button/Button';
|
||||
|
||||
export function MyComponent() {
|
||||
return (
|
||||
<Button variant="default">
|
||||
click me
|
||||
</Button>
|
||||
);
|
||||
}`}
|
||||
language="typescript"
|
||||
/>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
144
apps/web/src/components/style-guide/SectionContent.tsx
Normal file
144
apps/web/src/components/style-guide/SectionContent.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@tpmjs/ui/Card/Card';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||
import { DoDontCard, FieldsetSection, SubSection } from './shared';
|
||||
|
||||
export function SectionContent(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="8. content guidelines" id="content">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Voice and tone guidelines for consistent, helpful communication
|
||||
across the platform.
|
||||
</p>
|
||||
|
||||
<SubSection title="voice & tone">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-surface p-6 border-2 border-success">
|
||||
<h4 className="font-mono text-sm font-medium mb-4 text-success">we are</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>• <strong>Technical:</strong> Precise, accurate, developer-friendly</li>
|
||||
<li>• <strong>Direct:</strong> Say what needs to be said, no fluff</li>
|
||||
<li>• <strong>Helpful:</strong> Guide users to success</li>
|
||||
<li>• <strong>Neutral:</strong> Professional, not corporate</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-surface p-6 border-2 border-error">
|
||||
<h4 className="font-mono text-sm font-medium mb-4 text-error">we are not</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>• <strong>Marketing-speak:</strong> No "revolutionary" or "game-changing"</li>
|
||||
<li>• <strong>Cute:</strong> No jokes, puns, or playful language</li>
|
||||
<li>• <strong>Vague:</strong> No "something went wrong"</li>
|
||||
<li>• <strong>Condescending:</strong> No "simply" or "just"</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="microcopy rules">
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<DoDontCard type="do" title="Use action verbs for buttons">
|
||||
<div className="flex gap-3">
|
||||
<Button size="sm">publish</Button>
|
||||
<Button size="sm" variant="secondary">save draft</Button>
|
||||
<Button size="sm" variant="destructive">delete</Button>
|
||||
</div>
|
||||
</DoDontCard>
|
||||
<DoDontCard type="dont" title="Avoid generic labels">
|
||||
<div className="flex gap-3">
|
||||
<Button size="sm">submit</Button>
|
||||
<Button size="sm" variant="secondary">ok</Button>
|
||||
<Button size="sm" variant="destructive">yes</Button>
|
||||
</div>
|
||||
</DoDontCard>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">error message format</h4>
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Error messages should explain <strong>what happened</strong> and
|
||||
<strong> what to do next</strong>.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-error-light border border-error text-sm">
|
||||
<strong>Good:</strong> "API key is invalid. Generate a new key in your dashboard settings."
|
||||
</div>
|
||||
<div className="p-3 bg-error-light border border-error text-sm">
|
||||
<strong>Bad:</strong> "Error: Invalid credentials"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="empty states">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card variant="outline">
|
||||
<CardContent className="text-center py-12">
|
||||
<Icon icon="folder" size="lg" className="text-foreground-tertiary mx-auto mb-4" />
|
||||
<p className="font-mono text-sm text-foreground mb-2">no tools yet</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary mb-4">
|
||||
Publish your first tool to get started.
|
||||
</p>
|
||||
<Button size="sm">publish tool</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outline">
|
||||
<CardContent className="text-center py-12">
|
||||
<Icon icon="search" size="lg" className="text-foreground-tertiary mx-auto mb-4" />
|
||||
<p className="font-mono text-sm text-foreground mb-2">no results found</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary mb-4">
|
||||
Try adjusting your search or filters.
|
||||
</p>
|
||||
<Button size="sm" variant="outline">clear filters</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outline">
|
||||
<CardContent className="text-center py-12">
|
||||
<Icon icon="alertCircle" size="lg" className="text-error mx-auto mb-4" />
|
||||
<p className="font-mono text-sm text-foreground mb-2">failed to load</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary mb-4">
|
||||
Check your connection and try again.
|
||||
</p>
|
||||
<Button size="sm" variant="outline">retry</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="loading states">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">skeleton loading</h4>
|
||||
<p className="font-sans text-xs text-foreground-secondary mb-4">
|
||||
Use for content that will load quickly (<2s).
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-muted animate-pulse w-3/4" />
|
||||
<div className="h-4 bg-muted animate-pulse w-1/2" />
|
||||
<div className="h-4 bg-muted animate-pulse w-5/6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">spinner loading</h4>
|
||||
<p className="font-sans text-xs text-foreground-secondary mb-4">
|
||||
Use for actions or longer operations.
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Spinner size="sm" />
|
||||
<span className="font-mono text-sm text-foreground-secondary">publishing tool...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
419
apps/web/src/components/style-guide/SectionContentGuidelines.tsx
Normal file
419
apps/web/src/components/style-guide/SectionContentGuidelines.tsx
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@tpmjs/ui/Table/Table';
|
||||
import { FieldsetSection, SubSection, DoDontCard } from './shared';
|
||||
|
||||
const glossaryTerms = [
|
||||
{ term: 'Tool', definition: 'A reusable MCP server or utility that can be installed via npm', avoid: 'Package, Module, Plugin' },
|
||||
{ term: 'Agent', definition: 'An AI-powered assistant that uses tools to complete tasks', avoid: 'Bot, Assistant, AI' },
|
||||
{ term: 'Collection', definition: 'A curated group of related tools', avoid: 'Bundle, Set, Kit' },
|
||||
{ term: 'Publish', definition: 'Release a new version to the registry', avoid: 'Deploy, Ship, Push' },
|
||||
{ term: 'Install', definition: 'Add a tool to your project dependencies', avoid: 'Download, Get, Add' },
|
||||
{ term: 'Execute', definition: 'Run a tool with specific parameters', avoid: 'Invoke, Call, Trigger' },
|
||||
];
|
||||
|
||||
const verbConsistency = [
|
||||
{ action: 'Remove permanently', use: 'Delete', avoid: 'Remove, Erase, Destroy' },
|
||||
{ action: 'Remove from list', use: 'Remove', avoid: 'Delete, Drop, Clear' },
|
||||
{ action: 'Cancel access', use: 'Revoke', avoid: 'Remove, Delete, Cancel' },
|
||||
{ action: 'Make inactive', use: 'Disable', avoid: 'Turn off, Deactivate' },
|
||||
{ action: 'Make active', use: 'Enable', avoid: 'Turn on, Activate' },
|
||||
{ action: 'Start new', use: 'Create', avoid: 'Add, New, Make' },
|
||||
{ action: 'Change existing', use: 'Edit', avoid: 'Modify, Update, Change' },
|
||||
{ action: 'Look at details', use: 'View', avoid: 'See, Show, Open' },
|
||||
];
|
||||
|
||||
const iconUsageRules = [
|
||||
{ icon: 'check', usage: 'Success, completion, enabled', size: 'sm-md' },
|
||||
{ icon: 'x', usage: 'Close, cancel, remove, disabled', size: 'sm-md' },
|
||||
{ icon: 'alertCircle', usage: 'Error, critical issue', size: 'sm-md' },
|
||||
{ icon: 'alertTriangle', usage: 'Warning, caution needed', size: 'sm-md' },
|
||||
{ icon: 'info', usage: 'Information, help', size: 'sm-md' },
|
||||
{ icon: 'loader', usage: 'Loading, processing', size: 'sm-md' },
|
||||
{ icon: 'search', usage: 'Search input, find action', size: 'sm' },
|
||||
{ icon: 'edit', usage: 'Edit, configuration', size: 'sm-md' },
|
||||
{ icon: 'chevronRight', usage: 'Navigate forward, expand', size: 'xs-sm' },
|
||||
{ icon: 'chevronDown', usage: 'Expand, dropdown', size: 'xs-sm' },
|
||||
{ icon: 'plus', usage: 'Add, create new', size: 'sm-md' },
|
||||
{ icon: 'moreHorizontal', usage: 'More options menu', size: 'sm' },
|
||||
] as const;
|
||||
|
||||
export function SectionContentGuidelines(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="20. content & writing" id="content-guidelines">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Consistent language and terminology across the platform.
|
||||
These guidelines ensure clarity for users and maintainability for developers.
|
||||
</p>
|
||||
|
||||
<SubSection title="glossary">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Use these terms consistently throughout the platform.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>term</TableHead>
|
||||
<TableHead>definition</TableHead>
|
||||
<TableHead>avoid</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{glossaryTerms.map((item) => (
|
||||
<TableRow key={item.term}>
|
||||
<TableCell className="font-mono text-sm font-medium">{item.term}</TableCell>
|
||||
<TableCell className="text-sm text-foreground-secondary">{item.definition}</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-foreground-tertiary line-through">{item.avoid}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="verb consistency">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Use these verbs for common actions to maintain consistency.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>action</TableHead>
|
||||
<TableHead>use</TableHead>
|
||||
<TableHead>avoid</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{verbConsistency.map((item) => (
|
||||
<TableRow key={item.action}>
|
||||
<TableCell className="text-sm text-foreground-secondary">{item.action}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="default" size="sm">{item.use}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-foreground-tertiary line-through">{item.avoid}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="capitalization">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Follow these capitalization rules for UI text.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<DoDontCard type="do" title="Lowercase for UI elements">
|
||||
<div className="space-y-2 text-sm font-mono">
|
||||
<div className="p-2 bg-surface-2">create tool</div>
|
||||
<div className="p-2 bg-surface-2">view details</div>
|
||||
<div className="p-2 bg-surface-2">my collections</div>
|
||||
</div>
|
||||
</DoDontCard>
|
||||
<DoDontCard type="dont" title="Avoid title case in buttons">
|
||||
<div className="space-y-2 text-sm font-mono">
|
||||
<div className="p-2 bg-surface-2">Create Tool</div>
|
||||
<div className="p-2 bg-surface-2">View Details</div>
|
||||
<div className="p-2 bg-surface-2">My Collections</div>
|
||||
</div>
|
||||
</DoDontCard>
|
||||
</div>
|
||||
<div className="mt-4 bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-3">exceptions</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>• Proper nouns: "GitHub", "Anthropic", "Claude"</li>
|
||||
<li>• Product names: "TPMJS", "MCP"</li>
|
||||
<li>• Start of sentences in paragraphs</li>
|
||||
</ul>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="numbers & formatting">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Format numbers and data consistently.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-xs text-foreground-tertiary mb-3">numbers</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-secondary">Small:</span>
|
||||
<span className="font-mono">1, 42, 100</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-secondary">Large:</span>
|
||||
<span className="font-mono">1,234</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-secondary">Abbreviated:</span>
|
||||
<span className="font-mono">12.5k, 1.2M</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-xs text-foreground-tertiary mb-3">dates</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-secondary">Full:</span>
|
||||
<span className="font-mono">Jan 15, 2025</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-secondary">Relative:</span>
|
||||
<span className="font-mono">2 hours ago</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-secondary">ISO:</span>
|
||||
<span className="font-mono">2025-01-15</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-xs text-foreground-tertiary mb-3">units</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-secondary">Bytes:</span>
|
||||
<span className="font-mono">2.4 MB</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-secondary">Duration:</span>
|
||||
<span className="font-mono">3m 24s</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-secondary">Version:</span>
|
||||
<span className="font-mono">v1.2.3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="error messages">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Write error messages that are helpful and actionable.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<DoDontCard type="do" title="Helpful and specific">
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-error-light border-l-2 border-error text-sm">
|
||||
<p className="font-medium">Invalid email format</p>
|
||||
<p className="text-foreground-secondary text-xs mt-1">Please enter a valid email address like user@example.com</p>
|
||||
</div>
|
||||
<div className="p-3 bg-error-light border-l-2 border-error text-sm">
|
||||
<p className="font-medium">Tool name already exists</p>
|
||||
<p className="text-foreground-secondary text-xs mt-1">Choose a different name or update the existing tool</p>
|
||||
</div>
|
||||
</div>
|
||||
</DoDontCard>
|
||||
<DoDontCard type="dont" title="Vague or technical">
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-error-light border-l-2 border-error text-sm">
|
||||
<p className="font-medium">Invalid input</p>
|
||||
</div>
|
||||
<div className="p-3 bg-error-light border-l-2 border-error text-sm">
|
||||
<p className="font-medium">Error: EEXIST</p>
|
||||
</div>
|
||||
<div className="p-3 bg-error-light border-l-2 border-error text-sm">
|
||||
<p className="font-medium">Something went wrong</p>
|
||||
</div>
|
||||
</div>
|
||||
</DoDontCard>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionIconSystem(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="21. icon system" id="icon-system">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Icons from Lucide React, used consistently across components.
|
||||
All icons use 2px stroke weight and are available in multiple sizes.
|
||||
</p>
|
||||
|
||||
<SubSection title="icon library">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Source: <a href="https://lucide.dev" className="text-accent hover:underline">Lucide React</a>.
|
||||
Icons should be imported from <code className="text-xs bg-surface-2 px-1">@tpmjs/ui/Icon/Icon</code>.
|
||||
</p>
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">common icons</h4>
|
||||
<div className="grid grid-cols-4 md:grid-cols-8 gap-4">
|
||||
{iconUsageRules.map((item) => (
|
||||
<div key={item.icon} className="flex flex-col items-center gap-2 p-3 bg-surface-2">
|
||||
<Icon icon={item.icon} size="md" />
|
||||
<span className="font-mono text-[10px] text-foreground-tertiary">{item.icon}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="icon sizes">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Choose icon size based on context and surrounding content.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-surface p-4 border border-dashed border-border text-center">
|
||||
<Icon icon="edit" size="xs" className="mx-auto" />
|
||||
<p className="font-mono text-xs mt-2">xs (12px)</p>
|
||||
<p className="text-[10px] text-foreground-tertiary mt-1">Inline, tight spaces</p>
|
||||
</div>
|
||||
<div className="bg-surface p-4 border border-dashed border-border text-center">
|
||||
<Icon icon="edit" size="sm" className="mx-auto" />
|
||||
<p className="font-mono text-xs mt-2">sm (16px)</p>
|
||||
<p className="text-[10px] text-foreground-tertiary mt-1">Buttons, inputs</p>
|
||||
</div>
|
||||
<div className="bg-surface p-4 border border-dashed border-border text-center">
|
||||
<Icon icon="edit" size="md" className="mx-auto" />
|
||||
<p className="font-mono text-xs mt-2">md (20px)</p>
|
||||
<p className="text-[10px] text-foreground-tertiary mt-1">Default, nav items</p>
|
||||
</div>
|
||||
<div className="bg-surface p-4 border border-dashed border-border text-center">
|
||||
<Icon icon="edit" size="lg" className="mx-auto" />
|
||||
<p className="font-mono text-xs mt-2">lg (24px)</p>
|
||||
<p className="text-[10px] text-foreground-tertiary mt-1">Empty states, hero</p>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="icon usage guidelines">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
When and how to use specific icons.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>icon</TableHead>
|
||||
<TableHead>usage</TableHead>
|
||||
<TableHead>recommended size</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{iconUsageRules.map((item) => (
|
||||
<TableRow key={item.icon}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon={item.icon} size="sm" />
|
||||
<code className="text-xs">{item.icon}</code>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-foreground-secondary">{item.usage}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" size="sm">{item.size}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="icon styling">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Consistent styling rules for icons in different contexts.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">colors</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="check" size="sm" className="text-success" />
|
||||
<span className="text-sm text-foreground-secondary">Success states</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="alertCircle" size="sm" className="text-error" />
|
||||
<span className="text-sm text-foreground-secondary">Error states</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="alertTriangle" size="sm" className="text-warning" />
|
||||
<span className="text-sm text-foreground-secondary">Warning states</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="info" size="sm" className="text-accent" />
|
||||
<span className="text-sm text-foreground-secondary">Information</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="edit" size="sm" className="text-foreground-secondary" />
|
||||
<span className="text-sm text-foreground-secondary">Neutral/default</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">with text</h4>
|
||||
<div className="space-y-3">
|
||||
<button className="flex items-center gap-2 px-3 py-2 bg-accent text-white">
|
||||
<Icon icon="plus" size="sm" />
|
||||
<span className="font-mono text-sm">create tool</span>
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-3 py-2 border border-border">
|
||||
<span className="font-mono text-sm">settings</span>
|
||||
<Icon icon="chevronRight" size="xs" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-foreground-secondary">
|
||||
<Icon icon="clock" size="xs" />
|
||||
<span className="text-xs">2 hours ago</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="adding new icons">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Process for adding new icons to the system.
|
||||
</p>
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<ol className="space-y-3 text-sm text-foreground-secondary font-sans">
|
||||
<li className="flex gap-3">
|
||||
<span className="font-mono text-accent">1.</span>
|
||||
Check if a suitable icon exists in <a href="https://lucide.dev/icons" className="text-accent hover:underline">Lucide</a>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="font-mono text-accent">2.</span>
|
||||
Add the icon import to <code className="text-xs bg-surface-2 px-1">packages/ui/src/Icon/icons.ts</code>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="font-mono text-accent">3.</span>
|
||||
Add the icon name to the <code className="text-xs bg-surface-2 px-1">IconName</code> type
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="font-mono text-accent">4.</span>
|
||||
Document the usage in this style guide
|
||||
</li>
|
||||
</ol>
|
||||
<div className="mt-4 p-3 bg-surface-2">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-2">example: adding a new icon</p>
|
||||
<pre className="text-xs font-mono overflow-x-auto">
|
||||
{`// icons.ts
|
||||
import { Heart } from 'lucide-react';
|
||||
|
||||
export const icons = {
|
||||
// ...existing icons
|
||||
heart: Heart,
|
||||
};
|
||||
|
||||
export type IconName = keyof typeof icons;`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
142
apps/web/src/components/style-guide/SectionDataViz.tsx
Normal file
142
apps/web/src/components/style-guide/SectionDataViz.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
'use client';
|
||||
|
||||
import { AnimatedCounter } from '@tpmjs/ui/AnimatedCounter/AnimatedCounter';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
|
||||
import { StatCard } from '@tpmjs/ui/StatCard/StatCard';
|
||||
import { FieldsetSection, SubSection } from './shared';
|
||||
|
||||
interface SectionDataVizProps {
|
||||
counterValue: number;
|
||||
onCounterChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export function SectionDataViz({ counterValue, onCounterChange }: SectionDataVizProps): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="9. data visualization" id="data-viz">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Guidelines for charts, metrics, and data displays. Semantic colors only—
|
||||
no rainbow charts.
|
||||
</p>
|
||||
|
||||
<SubSection title="data color palette">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-accent p-4 text-center">
|
||||
<p className="font-mono text-sm text-white">primary data</p>
|
||||
</div>
|
||||
<div className="bg-foreground-secondary p-4 text-center">
|
||||
<p className="font-mono text-sm text-white">secondary data</p>
|
||||
</div>
|
||||
<div className="bg-foreground-tertiary p-4 text-center">
|
||||
<p className="font-mono text-sm text-white">tertiary data</p>
|
||||
</div>
|
||||
<div className="bg-border-strong p-4 text-center">
|
||||
<p className="font-mono text-sm text-white">baseline</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">color rules for data</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>• Use copper for primary/highlighted data series</li>
|
||||
<li>• Use grayscale for comparison and secondary series</li>
|
||||
<li>• Reserve semantic colors (success/warning/error) for threshold indicators</li>
|
||||
<li>• Create emphasis through opacity variation, not hue changes</li>
|
||||
<li>• Maximum 4 colors in any single visualization</li>
|
||||
</ul>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="quality score visualization">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-6">
|
||||
Tool quality scores use a consistent visual language across the platform.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
label="excellent"
|
||||
value={95}
|
||||
suffix="%"
|
||||
showBar
|
||||
barProgress={95}
|
||||
/>
|
||||
<StatCard
|
||||
label="good"
|
||||
value={75}
|
||||
suffix="%"
|
||||
showBar
|
||||
barProgress={75}
|
||||
/>
|
||||
<StatCard
|
||||
label="fair"
|
||||
value={50}
|
||||
suffix="%"
|
||||
showBar
|
||||
barProgress={50}
|
||||
/>
|
||||
<StatCard
|
||||
label="poor"
|
||||
value={25}
|
||||
suffix="%"
|
||||
showBar
|
||||
barProgress={25}
|
||||
/>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="progress indicators">
|
||||
<div className="space-y-6 max-w-xl">
|
||||
<div>
|
||||
<p className="font-mono text-sm text-foreground-secondary mb-2">primary (default)</p>
|
||||
<ProgressBar value={75} variant="primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-sm text-foreground-secondary mb-2">success (completion)</p>
|
||||
<ProgressBar value={100} variant="success" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-sm text-foreground-secondary mb-2">warning (approaching limit)</p>
|
||||
<ProgressBar value={85} variant="warning" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-sm text-foreground-secondary mb-2">danger (at limit)</p>
|
||||
<ProgressBar value={98} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="animated counters">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="font-mono text-3xl font-semibold">
|
||||
<AnimatedCounter value={counterValue} />
|
||||
</div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mt-2">downloads</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-mono text-3xl font-semibold">
|
||||
<AnimatedCounter value={Math.floor(counterValue * 0.8)} />
|
||||
</div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mt-2">users</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-mono text-3xl font-semibold">
|
||||
<AnimatedCounter value={Math.floor(counterValue * 0.05)} />
|
||||
</div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mt-2">tools</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-mono text-3xl font-semibold">
|
||||
<AnimatedCounter value={Math.floor(counterValue * 0.01)} />%
|
||||
</div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mt-2">uptime</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-6 justify-center">
|
||||
<Button size="sm" onClick={() => onCounterChange(counterValue + 1000)}>+1000</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onCounterChange(Math.max(0, counterValue - 1000))}>-1000</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => onCounterChange(Math.floor(Math.random() * 100000))}>random</Button>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
102
apps/web/src/components/style-guide/SectionIcons.tsx
Normal file
102
apps/web/src/components/style-guide/SectionIcons.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { FieldsetSection, SubSection } from './shared';
|
||||
|
||||
export function SectionIcons(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="10. iconography" id="icons">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Consistent icon usage for clear visual communication.
|
||||
</p>
|
||||
|
||||
<SubSection title="icon sizes">
|
||||
<div className="flex flex-wrap gap-8 items-end mb-8">
|
||||
<div className="text-center">
|
||||
<Icon icon="github" size="xs" />
|
||||
<p className="font-mono text-xs text-foreground-secondary mt-3">xs (12px)</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Icon icon="github" size="sm" />
|
||||
<p className="font-mono text-xs text-foreground-secondary mt-3">sm (16px)</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Icon icon="github" size="md" />
|
||||
<p className="font-mono text-xs text-foreground-secondary mt-3">md (20px)</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Icon icon="github" size="lg" />
|
||||
<p className="font-mono text-xs text-foreground-secondary mt-3">lg (24px)</p>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="usage rules">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">icon-only buttons</h4>
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Only allowed when the action is universally understood
|
||||
(close, search, menu) AND space is limited.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="icon" variant="ghost"><Icon icon="x" size="sm" /></Button>
|
||||
<Button size="icon" variant="ghost"><Icon icon="search" size="sm" /></Button>
|
||||
<Button size="icon" variant="ghost"><Icon icon="menu" size="sm" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">icon + label (preferred)</h4>
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Always include text labels when space permits for clarity.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm"><Icon icon="plus" size="sm" className="mr-2" /> add tool</Button>
|
||||
<Button size="sm" variant="outline"><Icon icon="upload" size="sm" className="mr-2" /> upload</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="semantic icons">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-surface p-4 border border-dashed border-border flex items-center gap-3">
|
||||
<Icon icon="check" size="md" className="text-success" />
|
||||
<span className="font-mono text-sm">success</span>
|
||||
</div>
|
||||
<div className="bg-surface p-4 border border-dashed border-border flex items-center gap-3">
|
||||
<Icon icon="alertCircle" size="md" className="text-warning" />
|
||||
<span className="font-mono text-sm">warning</span>
|
||||
</div>
|
||||
<div className="bg-surface p-4 border border-dashed border-border flex items-center gap-3">
|
||||
<Icon icon="x" size="md" className="text-error" />
|
||||
<span className="font-mono text-sm">error</span>
|
||||
</div>
|
||||
<div className="bg-surface p-4 border border-dashed border-border flex items-center gap-3">
|
||||
<Icon icon="info" size="md" className="text-info" />
|
||||
<span className="font-mono text-sm">info</span>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="all icons">
|
||||
<div className="grid grid-cols-6 md:grid-cols-8 lg:grid-cols-12 gap-6">
|
||||
{[
|
||||
'copy', 'github', 'check', 'x', 'chevronDown', 'chevronRight',
|
||||
'clock', 'link', 'sun', 'moon', 'discord', 'menu',
|
||||
'folder', 'plus', 'trash', 'edit', 'search', 'loader',
|
||||
'upload', 'alertCircle', 'globe', 'terminal', 'puzzle', 'message',
|
||||
'key', 'info', 'send', 'home', 'user', 'heart',
|
||||
'star', 'externalLink', 'arrowLeft', 'box', 'alertTriangle',
|
||||
].map((iconName) => (
|
||||
<div key={iconName} className="flex flex-col items-center gap-2" title={iconName}>
|
||||
<Icon icon={iconName as Parameters<typeof Icon>[0]['icon']} size="md" className="text-foreground" />
|
||||
<span className="font-mono text-[10px] text-foreground-tertiary truncate max-w-full">{iconName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
151
apps/web/src/components/style-guide/SectionLayout.tsx
Normal file
151
apps/web/src/components/style-guide/SectionLayout.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@tpmjs/ui/Table/Table';
|
||||
import { FieldsetSection, SubSection, TokenRow } from './shared';
|
||||
|
||||
interface SectionLayoutProps {
|
||||
density: 'compact' | 'comfortable' | 'spacious';
|
||||
onDensityChange: (density: 'compact' | 'comfortable' | 'spacious') => void;
|
||||
}
|
||||
|
||||
export function SectionLayout({ density, onDensityChange }: SectionLayoutProps): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="7. layout & responsiveness" id="layout">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Mobile-first responsive design with clear breakpoints and density modes
|
||||
for different use cases.
|
||||
</p>
|
||||
|
||||
<SubSection title="breakpoints">
|
||||
<div className="space-y-4 mb-8">
|
||||
<TokenRow name="sm" value="640px" preview={<Badge size="sm">mobile landscape</Badge>} />
|
||||
<TokenRow name="md" value="768px" preview={<Badge size="sm">tablet</Badge>} />
|
||||
<TokenRow name="lg" value="1024px" preview={<Badge size="sm">desktop</Badge>} />
|
||||
<TokenRow name="xl" value="1280px" preview={<Badge size="sm">wide</Badge>} />
|
||||
<TokenRow name="2xl" value="1536px" preview={<Badge size="sm">ultrawide</Badge>} />
|
||||
</div>
|
||||
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">responsive behaviors</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>• <strong>Sidebar:</strong> Collapsible on mobile, visible on lg+</li>
|
||||
<li>• <strong>Tables:</strong> Horizontal scroll on mobile, full width on lg+</li>
|
||||
<li>• <strong>Cards:</strong> Single column on mobile, grid on md+</li>
|
||||
<li>• <strong>Navigation:</strong> Hamburger menu on mobile, horizontal on lg+</li>
|
||||
</ul>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="density modes">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-6">
|
||||
Density modes adjust spacing, font size, and row heights for different contexts.
|
||||
Essential for data-dense developer tools.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-4 mb-6">
|
||||
<Button
|
||||
variant={density === 'compact' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onDensityChange('compact')}
|
||||
>
|
||||
compact
|
||||
</Button>
|
||||
<Button
|
||||
variant={density === 'comfortable' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onDensityChange('comfortable')}
|
||||
>
|
||||
comfortable
|
||||
</Button>
|
||||
<Button
|
||||
variant={density === 'spacious' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onDensityChange('spacious')}
|
||||
>
|
||||
spacious
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div data-density={density} className="border border-dashed border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="density-cell">name</TableHead>
|
||||
<TableHead className="density-cell">downloads</TableHead>
|
||||
<TableHead className="density-cell">status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="density-cell font-mono">@tpmjs/parser</TableCell>
|
||||
<TableCell className="density-cell font-mono">125,432</TableCell>
|
||||
<TableCell className="density-cell"><Badge variant="success" size="sm">active</Badge></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="density-cell font-mono">@tpmjs/validator</TableCell>
|
||||
<TableCell className="density-cell font-mono">89,231</TableCell>
|
||||
<TableCell className="density-cell"><Badge variant="success" size="sm">active</Badge></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="density-cell font-mono">@tpmjs/transform</TableCell>
|
||||
<TableCell className="density-cell font-mono">45,678</TableCell>
|
||||
<TableCell className="density-cell"><Badge variant="warning" size="sm">beta</Badge></TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-3 gap-4">
|
||||
<div className="bg-surface p-4 border border-dashed border-border text-center">
|
||||
<p className="font-mono text-xs text-foreground-secondary mb-1">compact</p>
|
||||
<p className="font-mono text-lg">36px rows</p>
|
||||
</div>
|
||||
<div className="bg-surface p-4 border border-dashed border-border text-center">
|
||||
<p className="font-mono text-xs text-foreground-secondary mb-1">comfortable</p>
|
||||
<p className="font-mono text-lg">48px rows</p>
|
||||
</div>
|
||||
<div className="bg-surface p-4 border border-dashed border-border text-center">
|
||||
<p className="font-mono text-xs text-foreground-secondary mb-1">spacious</p>
|
||||
<p className="font-mono text-lg">64px rows</p>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="grid system">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mb-3">12-column grid</p>
|
||||
<div className="grid grid-cols-12 gap-2">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div key={i} className="bg-surface border border-dashed border-border p-2 text-center">
|
||||
<span className="font-mono text-[10px] text-foreground-tertiary">{i + 1}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mb-3">sidebar + content (3 + 9)</p>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-3 bg-surface border border-dashed border-border p-4">
|
||||
<span className="font-mono text-xs">sidebar</span>
|
||||
</div>
|
||||
<div className="col-span-9 bg-surface border border-dashed border-border p-4">
|
||||
<span className="font-mono text-xs">main content</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
82
apps/web/src/components/style-guide/SectionMotion.tsx
Normal file
82
apps/web/src/components/style-guide/SectionMotion.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { FieldsetSection, SubSection, TokenRow } from './shared';
|
||||
|
||||
export function SectionMotion(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="5. motion" id="motion">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Motion communicates state changes, not decoration. Animations are fast
|
||||
by default and respect user preferences.
|
||||
</p>
|
||||
|
||||
<SubSection title="motion principles">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">fast by default</h4>
|
||||
<p className="font-sans text-sm text-foreground-secondary">
|
||||
Most transitions complete in 150-200ms. Users should never wait
|
||||
for animations to finish before interacting.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">state communication</h4>
|
||||
<p className="font-sans text-sm text-foreground-secondary">
|
||||
Motion indicates something changed: a panel opened, an item was
|
||||
selected, data updated. Never animate just for visual interest.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">linear for data</h4>
|
||||
<p className="font-sans text-sm text-foreground-secondary">
|
||||
Data updates (counters, progress bars, charts) use linear easing.
|
||||
This feels more precise and mechanical.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">respect preferences</h4>
|
||||
<p className="font-sans text-sm text-foreground-secondary">
|
||||
Honor prefers-reduced-motion. All animations disable when the
|
||||
user has requested reduced motion.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="duration tokens">
|
||||
<div className="space-y-4">
|
||||
<TokenRow name="--motion-instant" value="0ms" preview={<div className="w-full h-2 bg-accent" />} />
|
||||
<TokenRow name="--motion-fast" value="150ms" preview={<div className="w-full h-2 bg-accent motion-fast" />} />
|
||||
<TokenRow name="--motion-base" value="200ms" preview={<div className="w-full h-2 bg-accent motion-base" />} />
|
||||
<TokenRow name="--motion-slow" value="300ms" preview={<div className="w-full h-2 bg-accent motion-slow" />} />
|
||||
<TokenRow name="--motion-slower" value="500ms" preview={<div className="w-full h-2 bg-accent" />} />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="easing tokens">
|
||||
<div className="space-y-4">
|
||||
<TokenRow name="--easing-standard" value="cubic-bezier(0.4, 0, 0.2, 1)" />
|
||||
<TokenRow name="--easing-decelerate" value="cubic-bezier(0, 0, 0.2, 1)" />
|
||||
<TokenRow name="--easing-accelerate" value="cubic-bezier(0.4, 0, 1, 1)" />
|
||||
<TokenRow name="--easing-linear" value="linear" />
|
||||
<TokenRow name="--easing-spring" value="cubic-bezier(0.175, 0.885, 0.32, 1.275)" />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="interactive demo">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button className="motion-fast transition-all hover:scale-105">
|
||||
fast (150ms)
|
||||
</Button>
|
||||
<Button variant="secondary" className="motion-base transition-all hover:scale-105">
|
||||
base (200ms)
|
||||
</Button>
|
||||
<Button variant="outline" className="motion-slow transition-all hover:scale-105">
|
||||
slow (300ms)
|
||||
</Button>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
253
apps/web/src/components/style-guide/SectionPatternFeedback.tsx
Normal file
253
apps/web/src/components/style-guide/SectionPatternFeedback.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
|
||||
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||
import { FieldsetSection, SubSection } from './shared';
|
||||
|
||||
export function SectionPatternFeedback(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="16. feedback patterns" id="feedback-patterns">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Feedback patterns communicate status, progress, and system responses.
|
||||
Choose the right pattern based on context and urgency.
|
||||
</p>
|
||||
|
||||
<SubSection title="feedback decision tree">
|
||||
<div className="bg-surface p-6 border border-dashed border-border mb-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Badge variant="info" className="mt-1">Q1</Badge>
|
||||
<div>
|
||||
<p className="font-mono text-sm mb-2">Is this a page-level system message?</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary">
|
||||
Yes → Use <strong>Banner</strong> (maintenance, outage, announcement)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<Badge variant="info" className="mt-1">Q2</Badge>
|
||||
<div>
|
||||
<p className="font-mono text-sm mb-2">Is it a response to a user action?</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary">
|
||||
Yes → Use <strong>Toast</strong> (save, submit, delete confirmation)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<Badge variant="info" className="mt-1">Q3</Badge>
|
||||
<div>
|
||||
<p className="font-mono text-sm mb-2">Is it contextual to a specific element?</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary">
|
||||
Yes → Use <strong>Inline Alert</strong> (form errors, field hints)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="toast notifications">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Ephemeral messages that appear briefly and auto-dismiss.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Success toast */}
|
||||
<div className="bg-surface p-4 border-l-4 border-success flex items-start gap-3">
|
||||
<Icon icon="check" size="sm" className="text-success mt-0.5" />
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium">tool published</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary">
|
||||
@tpmjs/parser v1.0.0 is now live
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error toast */}
|
||||
<div className="bg-surface p-4 border-l-4 border-error flex items-start gap-3">
|
||||
<Icon icon="x" size="sm" className="text-error mt-0.5" />
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium">publish failed</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary">
|
||||
Package validation errors. Check your config.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning toast */}
|
||||
<div className="bg-surface p-4 border-l-4 border-warning flex items-start gap-3">
|
||||
<Icon icon="alertTriangle" size="sm" className="text-warning mt-0.5" />
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium">api rate limit</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary">
|
||||
You've used 90% of your monthly quota
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info toast */}
|
||||
<div className="bg-surface p-4 border-l-4 border-info flex items-start gap-3">
|
||||
<Icon icon="info" size="sm" className="text-info mt-0.5" />
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium">new version available</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary">
|
||||
Refresh to get the latest features
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-3">toast guidelines</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>• Auto-dismiss after 5 seconds (except errors)</li>
|
||||
<li>• Errors should require manual dismissal</li>
|
||||
<li>• Stack from bottom-right, newest at bottom</li>
|
||||
<li>• Maximum 3 visible toasts at once</li>
|
||||
</ul>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="inline alerts">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Persistent messages within content flow.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-success-light border border-success p-4 flex items-start gap-3">
|
||||
<Icon icon="check" size="sm" className="text-success mt-0.5" />
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-success">verification complete</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary">
|
||||
Your email has been verified. You can now publish tools.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-warning-light border border-warning p-4 flex items-start gap-3">
|
||||
<Icon icon="alertTriangle" size="sm" className="text-warning mt-0.5" />
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-warning">deprecated package</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary">
|
||||
This package is deprecated. Consider migrating to @tpmjs/v2.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-error-light border border-error p-4 flex items-start gap-3">
|
||||
<Icon icon="alertCircle" size="sm" className="text-error mt-0.5" />
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-error">critical security issue</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary">
|
||||
This version has known vulnerabilities. Update immediately.
|
||||
</p>
|
||||
<Button size="sm" variant="destructive" className="mt-3">update now</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="global banners">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
System-wide announcements that span the full width.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{/* Maintenance banner */}
|
||||
<div className="bg-warning text-foreground px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="alertTriangle" size="sm" />
|
||||
<span className="font-mono text-sm">
|
||||
Scheduled maintenance: Jan 15, 2-4am UTC. Some services may be unavailable.
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="text-foreground hover:bg-warning-light">
|
||||
<Icon icon="x" size="sm" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Outage banner */}
|
||||
<div className="bg-error text-white px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="alertCircle" size="sm" />
|
||||
<span className="font-mono text-sm">
|
||||
Service degradation detected. Some API calls may fail. We are investigating.
|
||||
</span>
|
||||
</div>
|
||||
<a href="#" className="font-mono text-sm underline hover:no-underline">status page</a>
|
||||
</div>
|
||||
|
||||
{/* Announcement banner */}
|
||||
<div className="bg-accent text-white px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="star" size="sm" />
|
||||
<span className="font-mono text-sm">
|
||||
New: AI-powered code review is now available for all tools!
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm">learn more</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="progress indicators">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
For long-running operations, show determinate progress when possible.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Determinate progress */}
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">determinate progress</h4>
|
||||
<p className="font-sans text-xs text-foreground-secondary mb-4">
|
||||
Use when you can calculate progress (file upload, batch processing).
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs font-mono">
|
||||
<span>uploading package.tgz</span>
|
||||
<span>67%</span>
|
||||
</div>
|
||||
<ProgressBar value={67} variant="primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indeterminate progress */}
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">indeterminate progress</h4>
|
||||
<p className="font-sans text-xs text-foreground-secondary mb-4">
|
||||
Use when duration is unknown (API calls, processing).
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Spinner size="sm" />
|
||||
<span className="font-mono text-sm text-foreground-secondary">
|
||||
validating package...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="retry patterns">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
When operations fail, provide clear retry options.
|
||||
</p>
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-error-light flex items-center justify-center flex-shrink-0">
|
||||
<Icon icon="alertCircle" size="md" className="text-error" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-mono text-sm font-medium mb-1">failed to load tools</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary mb-4">
|
||||
We couldn't connect to the server. This could be a network issue or the service may be temporarily unavailable.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm">retry</Button>
|
||||
<Button size="sm" variant="ghost">view details</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
231
apps/web/src/components/style-guide/SectionPatternForms.tsx
Normal file
231
apps/web/src/components/style-guide/SectionPatternForms.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { FormField } from '@tpmjs/ui/FormField/FormField';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { Label } from '@tpmjs/ui/Label/Label';
|
||||
import { Select } from '@tpmjs/ui/Select/Select';
|
||||
import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
|
||||
import { DoDontCard, FieldsetSection, SubSection } from './shared';
|
||||
|
||||
export function SectionPatternForms(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="15. form patterns" id="form-patterns">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Consistent form patterns for data entry, validation, and submission.
|
||||
Forms should guide users through tasks with clear feedback.
|
||||
</p>
|
||||
|
||||
<SubSection title="validation timing">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Choose validation timing based on the field type and user expectations.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">validate on blur</h4>
|
||||
<p className="font-sans text-xs text-foreground-secondary mb-4">
|
||||
Best for: format validation, required fields
|
||||
</p>
|
||||
<FormField label="email" error="Please enter a valid email address">
|
||||
<Input placeholder="user@example.com" state="error" />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">validate on submit</h4>
|
||||
<p className="font-sans text-xs text-foreground-secondary mb-4">
|
||||
Best for: async validation, complex rules
|
||||
</p>
|
||||
<FormField label="package name" helperText="We'll check if this name is available">
|
||||
<Input placeholder="@scope/package-name" />
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="error summary">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
For forms with multiple errors, show a summary at the top.
|
||||
</p>
|
||||
<div className="bg-surface border border-dashed border-border p-6">
|
||||
{/* Error summary */}
|
||||
<div className="bg-error-light border border-error p-4 mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon icon="alertCircle" size="sm" className="text-error" />
|
||||
<span className="font-mono text-sm font-medium text-error">please fix 3 errors</span>
|
||||
</div>
|
||||
<ul className="space-y-1 text-sm text-foreground-secondary font-sans">
|
||||
<li><a href="#name" className="text-error hover:underline">Name is required</a></li>
|
||||
<li><a href="#email" className="text-error hover:underline">Email format is invalid</a></li>
|
||||
<li><a href="#description" className="text-error hover:underline">Description must be at least 50 characters</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Form fields */}
|
||||
<div className="space-y-4">
|
||||
<FormField label="name" error="Name is required" required>
|
||||
<Input placeholder="Enter name" state="error" />
|
||||
</FormField>
|
||||
<FormField label="email" error="Email format is invalid" required>
|
||||
<Input placeholder="user@example.com" state="error" />
|
||||
</FormField>
|
||||
<FormField label="description" error="Description must be at least 50 characters">
|
||||
<Textarea placeholder="Describe your tool..." rows={3} />
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="help text placement">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<DoDontCard type="do" title="Help text below the input">
|
||||
<FormField
|
||||
label="api key"
|
||||
helperText="You can find this in your dashboard settings"
|
||||
>
|
||||
<Input placeholder="sk-xxxx..." />
|
||||
</FormField>
|
||||
</DoDontCard>
|
||||
<DoDontCard type="dont" title="Don't put help text above">
|
||||
<div>
|
||||
<Label>api key</Label>
|
||||
<p className="text-xs text-foreground-secondary mb-2">
|
||||
You can find this in your dashboard settings
|
||||
</p>
|
||||
<Input placeholder="sk-xxxx..." />
|
||||
</div>
|
||||
</DoDontCard>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="required vs optional">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-4">
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-4">when most fields are required</p>
|
||||
<div className="space-y-4">
|
||||
<FormField label="name" required>
|
||||
<Input placeholder="Tool name" />
|
||||
</FormField>
|
||||
<FormField label="version" required>
|
||||
<Input placeholder="1.0.0" />
|
||||
</FormField>
|
||||
<FormField label="description" helperText="(optional)">
|
||||
<Textarea placeholder="Describe your tool..." rows={2} />
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-4">when most fields are optional</p>
|
||||
<div className="space-y-4">
|
||||
<FormField label="website">
|
||||
<Input placeholder="https://example.com" />
|
||||
</FormField>
|
||||
<FormField label="twitter handle">
|
||||
<Input placeholder="@username" />
|
||||
</FormField>
|
||||
<FormField label="bio" helperText="required">
|
||||
<Textarea placeholder="Tell us about yourself..." rows={2} />
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<p className="font-sans text-sm text-foreground-secondary">
|
||||
<strong>Rule:</strong> Mark the minority. If most fields are required, mark optional fields.
|
||||
If most are optional, mark required fields.
|
||||
</p>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="field grouping">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Group related fields with visual hierarchy.
|
||||
</p>
|
||||
<div className="bg-surface border border-dashed border-border p-6">
|
||||
<div className="space-y-8">
|
||||
{/* Group 1 */}
|
||||
<div>
|
||||
<h4 className="font-mono text-sm font-medium mb-4 pb-2 border-b border-dashed border-border">
|
||||
package details
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField label="package name" required>
|
||||
<Input placeholder="@scope/package" />
|
||||
</FormField>
|
||||
<FormField label="version" required>
|
||||
<Input placeholder="1.0.0" />
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group 2 */}
|
||||
<div>
|
||||
<h4 className="font-mono text-sm font-medium mb-4 pb-2 border-b border-dashed border-border">
|
||||
author information
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField label="author name" required>
|
||||
<Input placeholder="Your name" />
|
||||
</FormField>
|
||||
<FormField label="email" required>
|
||||
<Input placeholder="author@example.com" />
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group 3 */}
|
||||
<div>
|
||||
<h4 className="font-mono text-sm font-medium mb-4 pb-2 border-b border-dashed border-border">
|
||||
repository
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField label="repository type">
|
||||
<Select
|
||||
placeholder="Select type..."
|
||||
options={[
|
||||
{ value: 'git', label: 'Git' },
|
||||
{ value: 'svn', label: 'SVN' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="repository url">
|
||||
<Input placeholder="https://github.com/..." />
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="danger zone">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Destructive actions require explicit confirmation.
|
||||
</p>
|
||||
<div className="bg-surface border border-error border-dashed p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Icon icon="alertTriangle" size="sm" className="text-error" />
|
||||
<h4 className="font-mono text-sm font-medium text-error">danger zone</h4>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-4 border-t border-dashed border-error">
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium">delete this tool</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary">
|
||||
Once deleted, this tool cannot be recovered.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="destructive" size="sm">delete tool</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-4 border-t border-dashed border-error">
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium">transfer ownership</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary">
|
||||
Transfer this tool to another user or organization.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">transfer</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
222
apps/web/src/components/style-guide/SectionPatternNavigation.tsx
Normal file
222
apps/web/src/components/style-guide/SectionPatternNavigation.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Breadcrumbs, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage } from '@tpmjs/ui/Breadcrumbs/Breadcrumbs';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Tabs } from '@tpmjs/ui/Tabs/Tabs';
|
||||
import { useState } from 'react';
|
||||
import { FieldsetSection, SubSection } from './shared';
|
||||
|
||||
export function SectionPatternNavigation(): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
return (
|
||||
<FieldsetSection title="14. navigation patterns" id="nav-patterns">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Navigation patterns for consistent wayfinding across the platform. Each pattern
|
||||
has specific use cases and accessibility requirements.
|
||||
</p>
|
||||
|
||||
<SubSection title="global navigation (header)">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
The primary header navigation appears on every page and provides access to main sections.
|
||||
</p>
|
||||
<div className="bg-surface border border-dashed border-border">
|
||||
{/* Mock header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-8">
|
||||
<span className="font-mono text-lg font-semibold">tpmjs</span>
|
||||
<nav className="flex items-center gap-6">
|
||||
<a href="#" className="font-mono text-sm text-foreground hover:text-accent transition-colors">tools</a>
|
||||
<a href="#" className="font-mono text-sm text-foreground-secondary hover:text-accent transition-colors">agents</a>
|
||||
<a href="#" className="font-mono text-sm text-foreground-secondary hover:text-accent transition-colors">docs</a>
|
||||
<a href="#" className="font-mono text-sm text-foreground-secondary hover:text-accent transition-colors">pricing</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm">sign in</Button>
|
||||
<Button size="sm">get started</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-3">guidelines</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>• Maximum 5-6 top-level items to avoid cognitive overload</li>
|
||||
<li>• Use visible active states (underline or background)</li>
|
||||
<li>• Logo always links to home</li>
|
||||
<li>• Auth actions stay in top-right corner</li>
|
||||
</ul>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="sidebar navigation">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Vertical sidebar for section-level navigation within the app.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Expanded sidebar */}
|
||||
<div className="bg-surface border border-dashed border-border p-4">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-4">expanded state</p>
|
||||
<nav className="space-y-1">
|
||||
<a href="#" className="flex items-center gap-3 px-3 py-2 bg-accent/10 text-accent font-mono text-sm">
|
||||
<Icon icon="home" size="sm" />
|
||||
<span>dashboard</span>
|
||||
</a>
|
||||
<a href="#" className="flex items-center gap-3 px-3 py-2 text-foreground-secondary hover:bg-surface-2 font-mono text-sm">
|
||||
<Icon icon="puzzle" size="sm" />
|
||||
<span>tools</span>
|
||||
<Badge size="sm" variant="outline" className="ml-auto">12</Badge>
|
||||
</a>
|
||||
<a href="#" className="flex items-center gap-3 px-3 py-2 text-foreground-secondary hover:bg-surface-2 font-mono text-sm">
|
||||
<Icon icon="key" size="sm" />
|
||||
<span>api keys</span>
|
||||
</a>
|
||||
<a href="#" className="flex items-center gap-3 px-3 py-2 text-foreground-secondary hover:bg-surface-2 font-mono text-sm">
|
||||
<Icon icon="user" size="sm" />
|
||||
<span>settings</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Collapsed sidebar */}
|
||||
<div className="bg-surface border border-dashed border-border p-4">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-4">collapsed state</p>
|
||||
<nav className="flex flex-col items-center space-y-2">
|
||||
<button className="p-3 bg-accent/10 text-accent" title="Dashboard">
|
||||
<Icon icon="home" size="sm" />
|
||||
</button>
|
||||
<button className="p-3 text-foreground-secondary hover:bg-surface-2" title="Tools">
|
||||
<Icon icon="puzzle" size="sm" />
|
||||
</button>
|
||||
<button className="p-3 text-foreground-secondary hover:bg-surface-2" title="API Keys">
|
||||
<Icon icon="key" size="sm" />
|
||||
</button>
|
||||
<button className="p-3 text-foreground-secondary hover:bg-surface-2" title="Settings">
|
||||
<Icon icon="user" size="sm" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-3">keyboard shortcuts</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-foreground-secondary font-sans">
|
||||
<div><kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">[</kbd> collapse/expand sidebar</div>
|
||||
<div><kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">g then h</kbd> go to home</div>
|
||||
<div><kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">g then t</kbd> go to tools</div>
|
||||
<div><kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">g then s</kbd> go to settings</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="breadcrumbs">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Show hierarchical location and enable navigation back to parent pages.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{/* Simple breadcrumb */}
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-3">default</p>
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#">home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#">tools</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>@tpmjs/parser</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
|
||||
{/* With icons */}
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-3">with icons</p>
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#" className="flex items-center gap-1">
|
||||
<Icon icon="home" size="xs" />
|
||||
<span>home</span>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#" className="flex items-center gap-1">
|
||||
<Icon icon="folder" size="xs" />
|
||||
<span>projects</span>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>my-tool</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="tab navigation">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
For switching between views within a page context.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'overview', label: 'overview' },
|
||||
{ id: 'readme', label: 'readme' },
|
||||
{ id: 'versions', label: 'versions', count: 24 },
|
||||
{ id: 'dependencies', label: 'dependencies', count: 3 },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
variant="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-3">keyboard behavior</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>• <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">←</kbd> / <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">→</kbd> navigate between tabs</li>
|
||||
<li>• <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Home</kbd> focus first tab</li>
|
||||
<li>• <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">End</kbd> focus last tab</li>
|
||||
<li>• <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Enter</kbd> / <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Space</kbd> activate focused tab</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="mobile navigation">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Hamburger menu pattern for narrow viewports.
|
||||
</p>
|
||||
<div className="max-w-sm">
|
||||
<div className="bg-surface border border-dashed border-border">
|
||||
{/* Mock mobile header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<span className="font-mono text-sm font-semibold">tpmjs</span>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Icon icon="menu" size="sm" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Expanded menu preview */}
|
||||
<div className="p-4 space-y-2">
|
||||
<a href="#" className="block px-3 py-2 font-mono text-sm text-foreground bg-accent/10">tools</a>
|
||||
<a href="#" className="block px-3 py-2 font-mono text-sm text-foreground-secondary">agents</a>
|
||||
<a href="#" className="block px-3 py-2 font-mono text-sm text-foreground-secondary">docs</a>
|
||||
<a href="#" className="block px-3 py-2 font-mono text-sm text-foreground-secondary">pricing</a>
|
||||
<div className="pt-4 border-t border-border space-y-2">
|
||||
<Button variant="outline" size="sm" className="w-full">sign in</Button>
|
||||
<Button size="sm" className="w-full">get started</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
288
apps/web/src/components/style-guide/SectionPatternSearch.tsx
Normal file
288
apps/web/src/components/style-guide/SectionPatternSearch.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { Select } from '@tpmjs/ui/Select/Select';
|
||||
import { FieldsetSection, SubSection } from './shared';
|
||||
|
||||
export function SectionPatternSearch(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="18. search & filtering" id="search-patterns">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Search and filter patterns help users find content quickly.
|
||||
Design for progressive disclosure and instant feedback.
|
||||
</p>
|
||||
|
||||
<SubSection title="search box states">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Search boxes should show clear states and provide helpful feedback.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Default state */}
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-3">default</p>
|
||||
<div className="relative">
|
||||
<Icon icon="search" size="sm" className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary" />
|
||||
<Input placeholder="search tools..." className="pl-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active state */}
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-3">active / has value</p>
|
||||
<div className="relative">
|
||||
<Icon icon="search" size="sm" className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary" />
|
||||
<Input defaultValue="parser" className="pl-10 pr-10" />
|
||||
<button className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground-tertiary hover:text-foreground">
|
||||
<Icon icon="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-3">loading</p>
|
||||
<div className="relative">
|
||||
<Icon icon="loader" size="sm" className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary animate-spin" />
|
||||
<Input defaultValue="validator" className="pl-10" readOnly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No results state */}
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-3">no results</p>
|
||||
<div className="relative">
|
||||
<Icon icon="search" size="sm" className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary" />
|
||||
<Input defaultValue="xyzabc123" className="pl-10 pr-10" />
|
||||
<button className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground-tertiary hover:text-foreground">
|
||||
<Icon icon="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-tertiary mt-2">No results found</p>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="filter chips">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Use chips to show active filters with easy removal.
|
||||
</p>
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Badge variant="default" className="pr-1 flex items-center gap-1">
|
||||
category: utility
|
||||
<button className="ml-1 hover:bg-accent-strong rounded">
|
||||
<Icon icon="x" size="xs" />
|
||||
</button>
|
||||
</Badge>
|
||||
<Badge variant="default" className="pr-1 flex items-center gap-1">
|
||||
status: active
|
||||
<button className="ml-1 hover:bg-accent-strong rounded">
|
||||
<Icon icon="x" size="xs" />
|
||||
</button>
|
||||
</Badge>
|
||||
<Badge variant="default" className="pr-1 flex items-center gap-1">
|
||||
downloads: >10k
|
||||
<button className="ml-1 hover:bg-accent-strong rounded">
|
||||
<Icon icon="x" size="xs" />
|
||||
</button>
|
||||
</Badge>
|
||||
<button className="font-mono text-xs text-accent hover:underline">clear all</button>
|
||||
</div>
|
||||
<p className="font-mono text-xs text-foreground-secondary">
|
||||
showing 42 of 128 tools
|
||||
</p>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="filter panel">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
For complex filtering, use a dedicated panel with grouped options.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Filter panel */}
|
||||
<div className="bg-surface border border-dashed border-border p-4">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">filters</h4>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="font-mono text-xs text-foreground-secondary block mb-2">category</label>
|
||||
<Select
|
||||
placeholder="all categories"
|
||||
options={[
|
||||
{ value: 'utility', label: 'Utility' },
|
||||
{ value: 'validation', label: 'Validation' },
|
||||
{ value: 'data', label: 'Data' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-xs text-foreground-secondary block mb-2">status</label>
|
||||
<Select
|
||||
placeholder="all statuses"
|
||||
options={[
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'beta', label: 'Beta' },
|
||||
{ value: 'deprecated', label: 'Deprecated' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-xs text-foreground-secondary block mb-2">min downloads</label>
|
||||
<Input type="number" placeholder="0" />
|
||||
</div>
|
||||
<div className="pt-4 border-t border-dashed border-border">
|
||||
<Button size="sm" className="w-full">apply filters</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results preview */}
|
||||
<div className="md:col-span-2 bg-surface border border-dashed border-border p-4">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-4">results area</p>
|
||||
<div className="space-y-2">
|
||||
{['@tpmjs/parser', '@tpmjs/validator', '@tpmjs/transform'].map((name) => (
|
||||
<div key={name} className="p-3 border border-dashed border-border flex items-center justify-between">
|
||||
<span className="font-mono text-sm">{name}</span>
|
||||
<Badge variant="success" size="sm">active</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="saved views">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Allow users to save and recall filter combinations.
|
||||
</p>
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-medium">saved views</span>
|
||||
<Badge variant="outline" size="sm">3</Badge>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Icon icon="plus" size="sm" className="mr-2" />
|
||||
save current
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<button className="w-full text-left px-3 py-2 bg-accent/10 font-mono text-sm flex items-center justify-between">
|
||||
<span>my active tools</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" size="sm">42 results</Badge>
|
||||
<Icon icon="check" size="sm" className="text-accent" />
|
||||
</div>
|
||||
</button>
|
||||
<button className="w-full text-left px-3 py-2 hover:bg-surface-2 font-mono text-sm flex items-center justify-between">
|
||||
<span>deprecated packages</span>
|
||||
<Badge variant="outline" size="sm">8 results</Badge>
|
||||
</button>
|
||||
<button className="w-full text-left px-3 py-2 hover:bg-surface-2 font-mono text-sm flex items-center justify-between">
|
||||
<span>high-download tools</span>
|
||||
<Badge variant="outline" size="sm">15 results</Badge>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="query syntax display">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
For power users, display the underlying query syntax.
|
||||
</p>
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-mono text-sm font-medium">query</span>
|
||||
<button className="font-mono text-xs text-accent hover:underline flex items-center gap-1">
|
||||
<Icon icon="copy" size="xs" />
|
||||
copy
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-surface-2 p-3 font-mono text-xs">
|
||||
<span className="text-accent">category:</span>
|
||||
<span className="text-foreground">utility</span>
|
||||
<span className="text-foreground-tertiary"> AND </span>
|
||||
<span className="text-accent">status:</span>
|
||||
<span className="text-foreground">active</span>
|
||||
<span className="text-foreground-tertiary"> AND </span>
|
||||
<span className="text-accent">downloads:</span>
|
||||
<span className="text-foreground">>10000</span>
|
||||
</div>
|
||||
<p className="font-sans text-xs text-foreground-tertiary mt-3">
|
||||
Tip: Use this syntax directly in the search box for quick filtering.
|
||||
</p>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="command palette">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Quick access to search and navigation via keyboard shortcut.
|
||||
</p>
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="bg-surface border border-border shadow-lg">
|
||||
{/* Search input */}
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="relative">
|
||||
<Icon icon="search" size="sm" className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary" />
|
||||
<Input placeholder="search or type a command..." className="pl-10 border-0 focus:ring-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results groups */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{/* Recent */}
|
||||
<div className="p-2">
|
||||
<p className="font-mono text-[10px] text-foreground-tertiary uppercase tracking-wide px-2 py-1">recent</p>
|
||||
<button className="w-full text-left px-3 py-2 hover:bg-surface-2 flex items-center gap-3">
|
||||
<Icon icon="clock" size="sm" className="text-foreground-tertiary" />
|
||||
<span className="font-mono text-sm">@tpmjs/parser</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-2 border-t border-border">
|
||||
<p className="font-mono text-[10px] text-foreground-tertiary uppercase tracking-wide px-2 py-1">actions</p>
|
||||
<button className="w-full text-left px-3 py-2 hover:bg-surface-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="plus" size="sm" className="text-foreground-tertiary" />
|
||||
<span className="font-mono text-sm">create new tool</span>
|
||||
</div>
|
||||
<kbd className="font-mono text-[10px] bg-surface-2 px-2 py-0.5">⌘N</kbd>
|
||||
</button>
|
||||
<button className="w-full text-left px-3 py-2 hover:bg-surface-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="home" size="sm" className="text-foreground-tertiary" />
|
||||
<span className="font-mono text-sm">go to dashboard</span>
|
||||
</div>
|
||||
<kbd className="font-mono text-[10px] bg-surface-2 px-2 py-0.5">⌘D</kbd>
|
||||
</button>
|
||||
<button className="w-full text-left px-3 py-2 hover:bg-surface-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="user" size="sm" className="text-foreground-tertiary" />
|
||||
<span className="font-mono text-sm">go to settings</span>
|
||||
</div>
|
||||
<kbd className="font-mono text-[10px] bg-surface-2 px-2 py-0.5">⌘,</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-3 py-2 flex items-center justify-between text-[10px] text-foreground-tertiary">
|
||||
<div className="flex items-center gap-3">
|
||||
<span><kbd className="bg-surface-2 px-1.5 py-0.5">↑↓</kbd> navigate</span>
|
||||
<span><kbd className="bg-surface-2 px-1.5 py-0.5">↵</kbd> select</span>
|
||||
<span><kbd className="bg-surface-2 px-1.5 py-0.5">esc</kbd> close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<kbd className="font-mono text-xs bg-surface px-3 py-1.5 border border-border">⌘K</kbd>
|
||||
<span className="font-sans text-xs text-foreground-secondary ml-2">to open command palette</span>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
253
apps/web/src/components/style-guide/SectionPatternTables.tsx
Normal file
253
apps/web/src/components/style-guide/SectionPatternTables.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Checkbox } from '@tpmjs/ui/Checkbox/Checkbox';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Pagination } from '@tpmjs/ui/Pagination/Pagination';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@tpmjs/ui/Table/Table';
|
||||
import { useState } from 'react';
|
||||
import { FieldsetSection, SubSection } from './shared';
|
||||
|
||||
export function SectionPatternTables(): React.ReactElement {
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedRows, setSelectedRows] = useState<string[]>([]);
|
||||
|
||||
const mockData = [
|
||||
{ id: '1', name: '@tpmjs/parser', category: 'utility', downloads: 125432, status: 'active', score: 0.92 },
|
||||
{ id: '2', name: '@tpmjs/validator', category: 'validation', downloads: 89231, status: 'active', score: 0.87 },
|
||||
{ id: '3', name: '@tpmjs/transform', category: 'data', downloads: 45678, status: 'beta', score: 0.81 },
|
||||
{ id: '4', name: '@tpmjs/executor', category: 'runtime', downloads: 34521, status: 'active', score: 0.78 },
|
||||
{ id: '5', name: '@tpmjs/config', category: 'utility', downloads: 23456, status: 'deprecated', score: 0.65 },
|
||||
];
|
||||
|
||||
const toggleRow = (id: string) => {
|
||||
setSelectedRows(prev =>
|
||||
prev.includes(id) ? prev.filter(r => r !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedRows.length === mockData.length) {
|
||||
setSelectedRows([]);
|
||||
} else {
|
||||
setSelectedRows(mockData.map(d => d.id));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldsetSection title="17. table patterns" id="table-patterns">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Data table patterns for displaying, sorting, filtering, and acting on tabular data.
|
||||
</p>
|
||||
|
||||
<SubSection title="sortable columns">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Click column headers to sort. Show sort direction with icons.
|
||||
</p>
|
||||
<div className="bg-surface border border-dashed border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="cursor-pointer hover:bg-surface-2">
|
||||
<div className="flex items-center gap-2">
|
||||
name
|
||||
<Icon icon="chevronDown" size="xs" className="text-foreground-tertiary" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>category</TableHead>
|
||||
<TableHead className="text-right cursor-pointer hover:bg-surface-2">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
downloads
|
||||
<Icon icon="chevronDown" size="xs" className="text-accent" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">score</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mockData.slice(0, 3).map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono">{row.name}</TableCell>
|
||||
<TableCell>{row.category}</TableCell>
|
||||
<TableCell className="text-right font-mono">{row.downloads.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right font-mono">{row.score}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="row selection + bulk actions">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Select rows to enable bulk actions. Show action bar when rows are selected.
|
||||
</p>
|
||||
<div className="bg-surface border border-dashed border-border">
|
||||
{/* Bulk action bar */}
|
||||
{selectedRows.length > 0 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-accent/10 border-b border-border">
|
||||
<span className="font-mono text-sm">
|
||||
{selectedRows.length} item{selectedRows.length !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline">export</Button>
|
||||
<Button size="sm" variant="destructive">delete</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedRows.length === mockData.length}
|
||||
onChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>name</TableHead>
|
||||
<TableHead>category</TableHead>
|
||||
<TableHead>status</TableHead>
|
||||
<TableHead className="text-right">downloads</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mockData.map((row) => (
|
||||
<TableRow key={row.id} className={selectedRows.includes(row.id) ? 'bg-accent/5' : ''}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(row.id)}
|
||||
onChange={() => toggleRow(row.id)}
|
||||
aria-label={`Select ${row.name}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">{row.name}</TableCell>
|
||||
<TableCell>{row.category}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={row.status === 'active' ? 'success' : row.status === 'deprecated' ? 'error' : 'warning'}
|
||||
size="sm"
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">{row.downloads.toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="pagination">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Use pagination for large datasets. Show page info and navigation.
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
{/* Full pagination */}
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-4">full pagination</p>
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={10}
|
||||
onPageChange={setPage}
|
||||
showFirstLast
|
||||
showPrevNext
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Simple pagination */}
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-4">simple pagination</p>
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={10}
|
||||
onPageChange={setPage}
|
||||
variant="simple"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Minimal pagination */}
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<p className="font-mono text-xs text-foreground-tertiary mb-4">minimal pagination</p>
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={10}
|
||||
onPageChange={setPage}
|
||||
variant="minimal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="empty & loading states">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Empty state */}
|
||||
<div className="bg-surface border border-dashed border-border">
|
||||
<div className="p-12 text-center">
|
||||
<Icon icon="folder" size="lg" className="text-foreground-tertiary mx-auto mb-4" />
|
||||
<p className="font-mono text-sm text-foreground mb-2">no tools found</p>
|
||||
<p className="font-sans text-xs text-foreground-secondary mb-4">
|
||||
Try adjusting your filters or create your first tool.
|
||||
</p>
|
||||
<Button size="sm">create tool</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
<div className="bg-surface border border-dashed border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>name</TableHead>
|
||||
<TableHead>category</TableHead>
|
||||
<TableHead className="text-right">downloads</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><div className="h-4 bg-muted animate-pulse w-32" /></TableCell>
|
||||
<TableCell><div className="h-4 bg-muted animate-pulse w-20" /></TableCell>
|
||||
<TableCell className="text-right"><div className="h-4 bg-muted animate-pulse w-16 ml-auto" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="density integration">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Tables respect the page density mode for compact views.
|
||||
</p>
|
||||
<div className="bg-surface p-4 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-3">row heights by density</h4>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<p className="font-mono text-xs text-foreground-tertiary">compact</p>
|
||||
<p className="font-mono text-lg">36px</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-mono text-xs text-foreground-tertiary">comfortable</p>
|
||||
<p className="font-mono text-lg">48px</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-mono text-xs text-foreground-tertiary">spacious</p>
|
||||
<p className="font-mono text-lg">64px</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/components/style-guide/SectionPrinciples.tsx
Normal file
62
apps/web/src/components/style-guide/SectionPrinciples.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use client';
|
||||
|
||||
import { FieldsetSection, PrincipleCard, SubSection } from './shared';
|
||||
|
||||
export function SectionPrinciples(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="1. design principles" id="principles">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
These principles guide every design decision in TPMJS. They ensure consistency
|
||||
and prevent drift as the system grows.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
|
||||
<PrincipleCard
|
||||
icon="terminal"
|
||||
title="clarity over decoration"
|
||||
description="Every element serves a purpose. Remove anything that doesn't help users accomplish their goals. Developer tools should feel efficient, not playful."
|
||||
/>
|
||||
<PrincipleCard
|
||||
icon="box"
|
||||
title="mechanical precision"
|
||||
description="Sharp corners communicate precision and control. Tools feel engineered, not organic. The interface should feel like a well-built instrument."
|
||||
/>
|
||||
<PrincipleCard
|
||||
icon="star"
|
||||
title="copper = active signal"
|
||||
description="The copper accent (#A6592D) indicates interactivity and importance. It's the 'hot metal' that draws attention to actions and key information."
|
||||
/>
|
||||
<PrincipleCard
|
||||
icon="sun"
|
||||
title="generous whitespace"
|
||||
description="Reduce cognitive load through breathing room. Dense interfaces slow users down. Space creates hierarchy and improves scanability."
|
||||
/>
|
||||
<PrincipleCard
|
||||
icon="key"
|
||||
title="accessible by default"
|
||||
description="WCAG AA compliance is the minimum. Focus states are never removed. Color is never the only indicator of meaning."
|
||||
/>
|
||||
<PrincipleCard
|
||||
icon="globe"
|
||||
title="developer-first"
|
||||
description="The system is built for developers using developer tools. Technical accuracy over marketing speak. Show don't tell."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SubSection title="design philosophy">
|
||||
<div className="bg-surface-2 p-6 border border-dashed border-border prose-width">
|
||||
<p className="font-sans text-foreground leading-relaxed mb-4">
|
||||
TPMJS is a <strong>developer platform</strong> for AI tools. The design reflects
|
||||
this through industrial aesthetics: sharp edges, technical typography, and
|
||||
a muted palette with copper as the signal color.
|
||||
</p>
|
||||
<p className="font-sans text-foreground-secondary leading-relaxed">
|
||||
Unlike consumer products that aim for delight, TPMJS aims for
|
||||
<strong> efficiency and trust</strong>. Users should feel confident that the
|
||||
interface will behave predictably and help them accomplish tasks quickly.
|
||||
</p>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/components/style-guide/SectionSpacing.tsx
Normal file
39
apps/web/src/components/style-guide/SectionSpacing.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
'use client';
|
||||
|
||||
import { FieldsetSection, SubSection, TokenRow } from './shared';
|
||||
|
||||
export function SectionSpacing(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="4. spacing" id="spacing">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Consistent spacing creates visual rhythm. Use the 4px base unit
|
||||
and generous whitespace for clarity.
|
||||
</p>
|
||||
|
||||
<SubSection title="spacing scale">
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 64].map((space) => (
|
||||
<div key={space} className="flex items-center gap-4">
|
||||
<span className="font-mono text-xs text-foreground-secondary w-12">{space}</span>
|
||||
<div
|
||||
className="bg-accent h-4"
|
||||
style={{ width: `${space * 4}px` }}
|
||||
/>
|
||||
<span className="font-mono text-xs text-foreground-tertiary">{space * 4}px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="semantic spacing">
|
||||
<div className="space-y-4">
|
||||
<TokenRow name="--spacing-tight" value="16px (4)" />
|
||||
<TokenRow name="--spacing-element" value="32px (8)" />
|
||||
<TokenRow name="--spacing-comfortable" value="48px (12)" />
|
||||
<TokenRow name="--spacing-component" value="64px (16)" />
|
||||
<TokenRow name="--spacing-section" value="128px (32)" />
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
95
apps/web/src/components/style-guide/SectionTheming.tsx
Normal file
95
apps/web/src/components/style-guide/SectionTheming.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { FieldsetSection, SubSection } from './shared';
|
||||
|
||||
export function SectionTheming(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="11. theming" id="theming">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Theme architecture and customization guidelines for consistent extension.
|
||||
</p>
|
||||
|
||||
<SubSection title="token architecture">
|
||||
<div className="bg-surface p-6 border border-dashed border-border mb-8">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline">core</Badge>
|
||||
<span className="font-mono text-sm text-foreground-secondary">
|
||||
Raw values: HSL colors, pixel sizes, timing functions
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline">semantic</Badge>
|
||||
<span className="font-mono text-sm text-foreground-secondary">
|
||||
Named tokens: --primary, --background, --motion-fast
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline">component</Badge>
|
||||
<span className="font-mono text-sm text-foreground-secondary">
|
||||
Component-specific: button-bg, input-border, card-shadow
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CodeBlock
|
||||
code={`/* Core tokens (don't override) */
|
||||
--copper-500: 22 57% 41%;
|
||||
|
||||
/* Semantic tokens (safe to override) */
|
||||
--primary: var(--copper-500);
|
||||
--primary-hover: 22 57% 35%;
|
||||
--accent: var(--primary);
|
||||
|
||||
/* Component tokens (for advanced customization) */
|
||||
--button-bg: hsl(var(--primary));
|
||||
--button-hover-bg: hsl(var(--primary-hover));`}
|
||||
language="css"
|
||||
/>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="dark mode">
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-4">
|
||||
Dark mode is opt-in via the <code className="font-mono bg-surface px-1">.dark</code> class
|
||||
on the root element. All semantic tokens have dark mode variants.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-background p-6 border border-dashed border-border">
|
||||
<p className="font-mono text-sm mb-4">light mode</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-8 h-8 bg-background border border-border" />
|
||||
<div className="w-8 h-8 bg-surface border border-border" />
|
||||
<div className="w-8 h-8 bg-accent" />
|
||||
<div className="w-8 h-8 bg-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#0F0E0D] p-6 border border-[#363230]">
|
||||
<p className="font-mono text-sm text-[#E8E5E2] mb-4">dark mode</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-8 h-8 bg-[#0F0E0D] border border-[#363230]" />
|
||||
<div className="w-8 h-8 bg-[#171514] border border-[#363230]" />
|
||||
<div className="w-8 h-8 bg-[#C96A38]" />
|
||||
<div className="w-8 h-8 bg-[#E8E5E2]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="embedding guidance">
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">for sdk users embedding tpmjs ui</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>• Import the CSS variables from <code className="font-mono bg-surface-2 px-1">@tpmjs/ui/styles</code></li>
|
||||
<li>• Override semantic tokens in your own CSS to match your brand</li>
|
||||
<li>• Do not override core tokens (raw values)</li>
|
||||
<li>• Test both light and dark modes if supporting theme switching</li>
|
||||
<li>• Use <code className="font-mono bg-surface-2 px-1">data-density</code> attribute for density control</li>
|
||||
</ul>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
104
apps/web/src/components/style-guide/SectionTypography.tsx
Normal file
104
apps/web/src/components/style-guide/SectionTypography.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
'use client';
|
||||
|
||||
import { FieldsetSection, SubSection, TokenRow } from './shared';
|
||||
|
||||
export function SectionTypography(): React.ReactElement {
|
||||
return (
|
||||
<FieldsetSection title="3. typography" id="typography">
|
||||
<p className="text-foreground-secondary mb-8 font-sans prose-width">
|
||||
Two font families create clear hierarchy: monospace for headings and technical
|
||||
content, sans-serif for body text and descriptions.
|
||||
</p>
|
||||
|
||||
<SubSection title="font families">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<p className="font-mono text-2xl mb-2">JetBrains Mono</p>
|
||||
<p className="font-mono text-sm text-foreground-secondary mb-4">--font-mono</p>
|
||||
<p className="font-mono text-sm">Used for: headings, code, data, labels, technical content</p>
|
||||
</div>
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<p className="font-sans text-2xl mb-2">Inter</p>
|
||||
<p className="font-mono text-sm text-foreground-secondary mb-4">--font-sans</p>
|
||||
<p className="font-sans text-sm">Used for: body text, descriptions, long-form content</p>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="type scale">
|
||||
<div className="space-y-4">
|
||||
<TokenRow name="--text-xs" value="12px / 0.75rem" preview={<span className="text-xs font-mono">Aa</span>} />
|
||||
<TokenRow name="--text-sm" value="14px / 0.875rem" preview={<span className="text-sm font-mono">Aa</span>} />
|
||||
<TokenRow name="--text-base" value="16px / 1rem" preview={<span className="text-base font-mono">Aa</span>} />
|
||||
<TokenRow name="--text-lg" value="18px / 1.125rem" preview={<span className="text-lg font-mono">Aa</span>} />
|
||||
<TokenRow name="--text-xl" value="20px / 1.25rem" preview={<span className="text-xl font-mono">Aa</span>} />
|
||||
<TokenRow name="--text-2xl" value="24px / 1.5rem" preview={<span className="text-2xl font-mono">Aa</span>} />
|
||||
<TokenRow name="--text-3xl" value="32px / 2rem" preview={<span className="text-3xl font-mono">Aa</span>} />
|
||||
<TokenRow name="--text-4xl" value="40px / 2.5rem" preview={<span className="text-4xl font-mono">Aa</span>} />
|
||||
<TokenRow name="--text-5xl" value="48px / 3rem" preview={<span className="text-5xl font-mono">Aa</span>} />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="line height">
|
||||
<div className="space-y-4">
|
||||
<TokenRow name="--leading-tight" value="1.2" preview={<div className="w-full h-3 bg-accent/20" />} />
|
||||
<TokenRow name="--leading-snug" value="1.4" preview={<div className="w-full h-4 bg-accent/20" />} />
|
||||
<TokenRow name="--leading-normal" value="1.6" preview={<div className="w-full h-5 bg-accent/20" />} />
|
||||
<TokenRow name="--leading-relaxed" value="1.7" preview={<div className="w-full h-6 bg-accent/20" />} />
|
||||
<TokenRow name="--leading-loose" value="1.8" preview={<div className="w-full h-7 bg-accent/20" />} />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="max line width">
|
||||
<div className="space-y-4 mb-8">
|
||||
<TokenRow name="--prose-width-narrow" value="45ch" />
|
||||
<TokenRow name="--prose-width" value="65ch (default)" />
|
||||
<TokenRow name="--prose-width-wide" value="80ch" />
|
||||
</div>
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<p className="font-sans text-sm text-foreground-secondary leading-relaxed prose-width">
|
||||
This paragraph is constrained to 65 characters per line, the optimal width
|
||||
for reading comprehension. Lines that are too long cause eye fatigue, while
|
||||
lines that are too short disrupt reading rhythm.
|
||||
</p>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="usage rules">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">monospace (JetBrains Mono)</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>• Page titles and section headings</li>
|
||||
<li>• Code snippets and technical content</li>
|
||||
<li>• Data values, metrics, timestamps</li>
|
||||
<li>• Form labels and button text</li>
|
||||
<li>• Table headers and numeric columns</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-surface p-6 border border-dashed border-border">
|
||||
<h4 className="font-mono text-sm font-medium mb-4">sans-serif (Inter)</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
|
||||
<li>• Body paragraphs and descriptions</li>
|
||||
<li>• Help text and instructions</li>
|
||||
<li>• Error messages and notifications</li>
|
||||
<li>• Marketing and explanatory content</li>
|
||||
<li>• Long-form documentation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="heading hierarchy">
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-mono text-5xl font-semibold tracking-tight lowercase">heading 1 (48px)</h1>
|
||||
<h2 className="font-mono text-4xl font-semibold tracking-tight lowercase">heading 2 (40px)</h2>
|
||||
<h3 className="font-mono text-3xl font-semibold tracking-tight lowercase">heading 3 (32px)</h3>
|
||||
<h4 className="font-mono text-2xl font-semibold tracking-tight lowercase">heading 4 (24px)</h4>
|
||||
<h5 className="font-mono text-xl font-medium lowercase">heading 5 (20px)</h5>
|
||||
<h6 className="font-mono text-lg font-medium lowercase">heading 6 (18px)</h6>
|
||||
</div>
|
||||
</SubSection>
|
||||
</FieldsetSection>
|
||||
);
|
||||
}
|
||||
40
apps/web/src/components/style-guide/index.ts
Normal file
40
apps/web/src/components/style-guide/index.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Shared helpers
|
||||
export {
|
||||
FieldsetSection,
|
||||
SubSection,
|
||||
ColorCard,
|
||||
DoDontCard,
|
||||
PrincipleCard,
|
||||
TokenRow,
|
||||
NavItem,
|
||||
} from './shared';
|
||||
|
||||
// Foundation sections
|
||||
export { SectionPrinciples } from './SectionPrinciples';
|
||||
export { SectionColors } from './SectionColors';
|
||||
export { SectionTypography } from './SectionTypography';
|
||||
export { SectionSpacing } from './SectionSpacing';
|
||||
export { SectionMotion } from './SectionMotion';
|
||||
|
||||
// Systems sections
|
||||
export { SectionAccessibility } from './SectionAccessibility';
|
||||
export { SectionLayout } from './SectionLayout';
|
||||
export { SectionContent } from './SectionContent';
|
||||
export { SectionDataViz } from './SectionDataViz';
|
||||
export { SectionIcons } from './SectionIcons';
|
||||
|
||||
// Implementation sections
|
||||
export { SectionTheming } from './SectionTheming';
|
||||
export { SectionComponents } from './SectionComponents';
|
||||
export { SectionComponentAPIs } from './SectionComponentAPIs';
|
||||
|
||||
// Pattern library sections
|
||||
export { SectionPatternNavigation } from './SectionPatternNavigation';
|
||||
export { SectionPatternForms } from './SectionPatternForms';
|
||||
export { SectionPatternFeedback } from './SectionPatternFeedback';
|
||||
export { SectionPatternTables } from './SectionPatternTables';
|
||||
export { SectionPatternSearch } from './SectionPatternSearch';
|
||||
|
||||
// Additional sections
|
||||
export { SectionA11yChecklists } from './SectionA11yChecklists';
|
||||
export { SectionContentGuidelines, SectionIconSystem } from './SectionContentGuidelines';
|
||||
169
apps/web/src/components/style-guide/shared.tsx
Normal file
169
apps/web/src/components/style-guide/shared.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
'use client';
|
||||
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
|
||||
/**
|
||||
* Fieldset-style container with legend label
|
||||
*/
|
||||
export function FieldsetSection({
|
||||
title,
|
||||
children,
|
||||
id,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<fieldset id={id} className="border border-dashed border-border p-8 mb-16 scroll-mt-24">
|
||||
<legend className="font-mono text-sm text-foreground-secondary px-3 lowercase">
|
||||
{title}
|
||||
</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-section with title
|
||||
*/
|
||||
export function SubSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="mb-10 last:mb-0">
|
||||
<h3 className="font-mono text-base font-medium mb-6 text-foreground lowercase">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Color swatch card
|
||||
*/
|
||||
export function ColorCard({
|
||||
name,
|
||||
color,
|
||||
hex,
|
||||
desc,
|
||||
textLight = false,
|
||||
}: {
|
||||
name: string;
|
||||
color: string;
|
||||
hex: string;
|
||||
desc: string;
|
||||
textLight?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className={`${color} p-4 border border-dashed border-border`}>
|
||||
<div className={`font-mono text-sm font-medium ${textLight ? 'text-white' : 'text-foreground'}`}>
|
||||
{name}
|
||||
</div>
|
||||
<div className={`font-mono text-xs ${textLight ? 'text-white/80' : 'text-foreground-secondary'}`}>
|
||||
{hex}
|
||||
</div>
|
||||
<div className={`font-mono text-xs mt-2 ${textLight ? 'text-white/60' : 'text-foreground-tertiary'}`}>
|
||||
{desc}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Do/Don't example card
|
||||
*/
|
||||
export function DoDontCard({
|
||||
type,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
type: 'do' | 'dont';
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
const isDo = type === 'do';
|
||||
return (
|
||||
<div className={`border-2 ${isDo ? 'border-success' : 'border-error'} p-4`}>
|
||||
<div className={`flex items-center gap-2 mb-3 ${isDo ? 'text-success' : 'text-error'}`}>
|
||||
<Icon icon={isDo ? 'check' : 'x'} size="sm" />
|
||||
<span className="font-mono text-sm font-medium uppercase">{isDo ? 'do' : "don't"}</span>
|
||||
</div>
|
||||
<p className="font-mono text-xs text-foreground-secondary mb-3">{title}</p>
|
||||
<div className="bg-surface p-3 border border-dashed border-border">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Principle card with icon
|
||||
*/
|
||||
export function PrincipleCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="border border-dashed border-border p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-accent/10 flex items-center justify-center">
|
||||
<Icon icon={icon as Parameters<typeof Icon>[0]['icon']} size="md" className="text-accent" />
|
||||
</div>
|
||||
<h4 className="font-mono text-base font-medium text-foreground">{title}</h4>
|
||||
</div>
|
||||
<p className="font-sans text-sm text-foreground-secondary leading-relaxed">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token display row
|
||||
*/
|
||||
export function TokenRow({
|
||||
name,
|
||||
value,
|
||||
preview,
|
||||
}: {
|
||||
name: string;
|
||||
value: string;
|
||||
preview?: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-4 py-3 border-b border-dashed border-border last:border-0">
|
||||
<code className="font-mono text-xs text-accent w-48">{name}</code>
|
||||
<span className="font-mono text-xs text-foreground-secondary flex-1">{value}</span>
|
||||
{preview && <div className="w-24">{preview}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation item for table of contents
|
||||
*/
|
||||
export function NavItem({
|
||||
href,
|
||||
children,
|
||||
indent = false,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
indent?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={`block font-mono text-sm text-foreground-secondary hover:text-accent transition-colors ${indent ? 'pl-4' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,5 +8,22 @@ export default {
|
|||
'./src/components/**/*.{ts,tsx}',
|
||||
'../../packages/ui/src/**/*.ts',
|
||||
],
|
||||
safelist: [
|
||||
...(baseConfig.safelist || []),
|
||||
// Extended color utilities for style guide
|
||||
'bg-accent-strong',
|
||||
'bg-accent-muted',
|
||||
'bg-surface-2',
|
||||
'bg-surface-3',
|
||||
'bg-success-light',
|
||||
'bg-warning-light',
|
||||
'bg-error-light',
|
||||
'bg-info-light',
|
||||
'text-foreground-secondary',
|
||||
'text-foreground-tertiary',
|
||||
'text-foreground-muted',
|
||||
'border-border-strong',
|
||||
'border-border-subtle',
|
||||
],
|
||||
plugins: [...(baseConfig.plugins || []), require('@tailwindcss/typography')],
|
||||
} satisfies Config;
|
||||
|
|
|
|||
|
|
@ -18,18 +18,40 @@ export default {
|
|||
'dotted-grid-background',
|
||||
'blueprint-background',
|
||||
'grid-background',
|
||||
// Extended color utilities for style guide
|
||||
'bg-accent-strong',
|
||||
'bg-accent-muted',
|
||||
'bg-surface-2',
|
||||
'bg-surface-3',
|
||||
'bg-success-light',
|
||||
'bg-warning-light',
|
||||
'bg-error-light',
|
||||
'bg-info-light',
|
||||
'text-foreground-secondary',
|
||||
'text-foreground-tertiary',
|
||||
'text-foreground-muted',
|
||||
'border-border-strong',
|
||||
'border-border-subtle',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Backgrounds & Surfaces
|
||||
// Backgrounds & Surfaces - Layered system
|
||||
background: 'hsl(var(--background))',
|
||||
surface: 'hsl(var(--surface))',
|
||||
surface: {
|
||||
DEFAULT: 'hsl(var(--surface))',
|
||||
2: 'hsl(var(--surface-2))',
|
||||
3: 'hsl(var(--surface-3))',
|
||||
secondary: 'hsl(var(--surface-secondary))',
|
||||
elevated: 'hsl(var(--surface-elevated))',
|
||||
overlay: 'hsl(var(--surface-overlay))',
|
||||
},
|
||||
// Legacy flat aliases
|
||||
'surface-secondary': 'hsl(var(--surface-secondary))',
|
||||
'surface-elevated': 'hsl(var(--surface-elevated))',
|
||||
'surface-overlay': 'hsl(var(--surface-overlay))',
|
||||
|
||||
// Foreground (Text)
|
||||
// Foreground (Text) - Clear hierarchy
|
||||
foreground: {
|
||||
DEFAULT: 'hsl(var(--foreground))',
|
||||
secondary: 'hsl(var(--foreground-secondary))',
|
||||
|
|
@ -37,57 +59,75 @@ export default {
|
|||
muted: 'hsl(var(--foreground-muted))',
|
||||
},
|
||||
|
||||
// Borders
|
||||
// Borders - Multiple weights
|
||||
border: {
|
||||
DEFAULT: 'hsl(var(--border))',
|
||||
strong: 'hsl(var(--border-strong))',
|
||||
subtle: 'hsl(var(--border-subtle))',
|
||||
},
|
||||
|
||||
// Interactive States
|
||||
// Primary - With hover/active states
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
hover: 'hsl(var(--primary-hover))',
|
||||
active: 'hsl(var(--primary-active))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
|
||||
// Secondary - With hover state
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
hover: 'hsl(var(--secondary-hover))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
|
||||
// Accent - Copper variations
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
strong: 'hsl(var(--accent-strong))',
|
||||
muted: 'hsl(var(--accent-muted))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
|
||||
// Muted
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
|
||||
// Status Colors
|
||||
// Status Colors - With light variants
|
||||
success: {
|
||||
DEFAULT: 'hsl(var(--success))',
|
||||
light: 'hsl(var(--success-light))',
|
||||
foreground: 'hsl(var(--success-foreground))',
|
||||
},
|
||||
error: {
|
||||
DEFAULT: 'hsl(var(--error))',
|
||||
light: 'hsl(var(--error-light))',
|
||||
foreground: 'hsl(var(--error-foreground))',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: 'hsl(var(--warning))',
|
||||
light: 'hsl(var(--warning-light))',
|
||||
foreground: 'hsl(var(--warning-foreground))',
|
||||
},
|
||||
info: {
|
||||
DEFAULT: 'hsl(var(--info))',
|
||||
light: 'hsl(var(--info-light))',
|
||||
foreground: 'hsl(var(--info-foreground))',
|
||||
},
|
||||
|
||||
// Destructive (legacy support)
|
||||
// Destructive (alias for error)
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
|
||||
// Form Elements
|
||||
input: 'hsl(var(--input))',
|
||||
input: {
|
||||
DEFAULT: 'hsl(var(--input))',
|
||||
focus: 'hsl(var(--input-focus))',
|
||||
},
|
||||
ring: 'hsl(var(--ring))',
|
||||
|
||||
// Card
|
||||
|
|
|
|||
|
|
@ -166,6 +166,58 @@
|
|||
"./Table/Table": {
|
||||
"types": "./dist/Table/Table.d.ts",
|
||||
"default": "./dist/Table/Table.js"
|
||||
},
|
||||
"./Modal/Modal": {
|
||||
"types": "./dist/Modal/Modal.d.ts",
|
||||
"default": "./dist/Modal/Modal.js"
|
||||
},
|
||||
"./Toast/Toast": {
|
||||
"types": "./dist/Toast/Toast.d.ts",
|
||||
"default": "./dist/Toast/Toast.js"
|
||||
},
|
||||
"./Drawer/Drawer": {
|
||||
"types": "./dist/Drawer/Drawer.d.ts",
|
||||
"default": "./dist/Drawer/Drawer.js"
|
||||
},
|
||||
"./Popover/Popover": {
|
||||
"types": "./dist/Popover/Popover.d.ts",
|
||||
"default": "./dist/Popover/Popover.js"
|
||||
},
|
||||
"./Tooltip/Tooltip": {
|
||||
"types": "./dist/Tooltip/Tooltip.d.ts",
|
||||
"default": "./dist/Tooltip/Tooltip.js"
|
||||
},
|
||||
"./DropdownMenu/DropdownMenu": {
|
||||
"types": "./dist/DropdownMenu/DropdownMenu.d.ts",
|
||||
"default": "./dist/DropdownMenu/DropdownMenu.js"
|
||||
},
|
||||
"./Breadcrumbs/Breadcrumbs": {
|
||||
"types": "./dist/Breadcrumbs/Breadcrumbs.d.ts",
|
||||
"default": "./dist/Breadcrumbs/Breadcrumbs.js"
|
||||
},
|
||||
"./Pagination/Pagination": {
|
||||
"types": "./dist/Pagination/Pagination.d.ts",
|
||||
"default": "./dist/Pagination/Pagination.js"
|
||||
},
|
||||
"./Accordion/Accordion": {
|
||||
"types": "./dist/Accordion/Accordion.d.ts",
|
||||
"default": "./dist/Accordion/Accordion.js"
|
||||
},
|
||||
"./Skeleton/Skeleton": {
|
||||
"types": "./dist/Skeleton/Skeleton.d.ts",
|
||||
"default": "./dist/Skeleton/Skeleton.js"
|
||||
},
|
||||
"./InstallSnippet/InstallSnippet": {
|
||||
"types": "./dist/InstallSnippet/InstallSnippet.d.ts",
|
||||
"default": "./dist/InstallSnippet/InstallSnippet.js"
|
||||
},
|
||||
"./QualityScore/QualityScore": {
|
||||
"types": "./dist/QualityScore/QualityScore.d.ts",
|
||||
"default": "./dist/QualityScore/QualityScore.js"
|
||||
},
|
||||
"./ToolCard/ToolCard": {
|
||||
"types": "./dist/ToolCard/ToolCard.d.ts",
|
||||
"default": "./dist/ToolCard/ToolCard.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
|
|
|||
280
packages/ui/src/Accordion/Accordion.tsx
Normal file
280
packages/ui/src/Accordion/Accordion.tsx
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@tpmjs/utils/cn';
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import type {
|
||||
AccordionContentProps,
|
||||
AccordionContextValue,
|
||||
AccordionItemContextValue,
|
||||
AccordionItemProps,
|
||||
AccordionProps,
|
||||
AccordionTriggerProps,
|
||||
} from './types';
|
||||
import {
|
||||
accordionContentInnerVariants,
|
||||
accordionContentVariants,
|
||||
accordionItemVariants,
|
||||
accordionTriggerIconVariants,
|
||||
accordionTriggerVariants,
|
||||
accordionVariants,
|
||||
} from './variants';
|
||||
|
||||
// Accordion context
|
||||
const AccordionContext = createContext<AccordionContextValue | null>(null);
|
||||
|
||||
// Accordion item context
|
||||
const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Accordion component
|
||||
*
|
||||
* A vertically stacked set of interactive headings that reveal content.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import {
|
||||
* Accordion,
|
||||
* AccordionItem,
|
||||
* AccordionTrigger,
|
||||
* AccordionContent,
|
||||
* } from '@tpmjs/ui/Accordion/Accordion';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return (
|
||||
* <Accordion type="single" defaultValue="item-1">
|
||||
* <AccordionItem value="item-1">
|
||||
* <AccordionTrigger>Section 1</AccordionTrigger>
|
||||
* <AccordionContent>Content for section 1</AccordionContent>
|
||||
* </AccordionItem>
|
||||
* <AccordionItem value="item-2">
|
||||
* <AccordionTrigger>Section 2</AccordionTrigger>
|
||||
* <AccordionContent>Content for section 2</AccordionContent>
|
||||
* </AccordionItem>
|
||||
* </Accordion>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Accordion = forwardRef<HTMLDivElement, AccordionProps>(
|
||||
(
|
||||
{
|
||||
type = 'single',
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
collapsible = true,
|
||||
variant = 'default',
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// Normalize value to array format internally
|
||||
const normalizeValue = (val: string | string[] | undefined): string[] => {
|
||||
if (val === undefined) return [];
|
||||
return Array.isArray(val) ? val : [val];
|
||||
};
|
||||
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const [internalValue, setInternalValue] = useState<string[]>(
|
||||
normalizeValue(defaultValue)
|
||||
);
|
||||
|
||||
const value = isControlled ? normalizeValue(controlledValue) : internalValue;
|
||||
|
||||
const toggleItem = useCallback(
|
||||
(itemValue: string) => {
|
||||
let newValue: string[];
|
||||
|
||||
if (type === 'single') {
|
||||
if (value.includes(itemValue)) {
|
||||
// If collapsible, allow closing; otherwise keep it open
|
||||
newValue = collapsible ? [] : value;
|
||||
} else {
|
||||
newValue = [itemValue];
|
||||
}
|
||||
} else {
|
||||
// Multiple: toggle the item
|
||||
if (value.includes(itemValue)) {
|
||||
newValue = value.filter((v) => v !== itemValue);
|
||||
} else {
|
||||
newValue = [...value, itemValue];
|
||||
}
|
||||
}
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
|
||||
// Emit in the format expected by the type
|
||||
if (type === 'single') {
|
||||
onValueChange?.(newValue[0] || '');
|
||||
} else {
|
||||
onValueChange?.(newValue);
|
||||
}
|
||||
},
|
||||
[type, value, collapsible, isControlled, onValueChange]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
type,
|
||||
value,
|
||||
toggleItem,
|
||||
variant,
|
||||
}),
|
||||
[type, value, toggleItem, variant]
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionContext.Provider value={contextValue}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(accordionVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Accordion.displayName = 'Accordion';
|
||||
|
||||
/**
|
||||
* AccordionItem component
|
||||
*/
|
||||
export const AccordionItem = forwardRef<HTMLDivElement, AccordionItemProps>(
|
||||
({ value, disabled = false, children, className, ...props }, ref) => {
|
||||
const context = useContext(AccordionContext);
|
||||
if (!context) {
|
||||
throw new Error('AccordionItem must be used within an Accordion');
|
||||
}
|
||||
|
||||
const isExpanded = context.value.includes(value);
|
||||
|
||||
const itemContextValue = useMemo(
|
||||
() => ({
|
||||
value,
|
||||
disabled,
|
||||
isExpanded,
|
||||
}),
|
||||
[value, disabled, isExpanded]
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionItemContext.Provider value={itemContextValue}>
|
||||
<div
|
||||
ref={ref}
|
||||
data-state={isExpanded ? 'open' : 'closed'}
|
||||
className={cn(accordionItemVariants({ variant: context.variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionItemContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AccordionItem.displayName = 'AccordionItem';
|
||||
|
||||
/**
|
||||
* AccordionTrigger component
|
||||
*/
|
||||
export const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerProps>(
|
||||
({ icon, children, className, ...props }, ref) => {
|
||||
const accordionContext = useContext(AccordionContext);
|
||||
const itemContext = useContext(AccordionItemContext);
|
||||
|
||||
if (!accordionContext || !itemContext) {
|
||||
throw new Error('AccordionTrigger must be used within an AccordionItem');
|
||||
}
|
||||
|
||||
const { toggleItem } = accordionContext;
|
||||
const { value, disabled, isExpanded } = itemContext;
|
||||
|
||||
const triggerId = useId();
|
||||
const contentId = useId();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!disabled) {
|
||||
toggleItem(value);
|
||||
}
|
||||
}, [disabled, toggleItem, value]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
},
|
||||
[handleClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
id={triggerId}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={contentId}
|
||||
disabled={disabled}
|
||||
className={cn(accordionTriggerVariants({ disabled: disabled ? 'true' : 'false' }), className)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
>
|
||||
<span className="flex-1">{children}</span>
|
||||
<span className={accordionTriggerIconVariants({ expanded: isExpanded ? 'true' : 'false' })}>
|
||||
{icon ?? <Icon icon="chevronDown" size="sm" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AccordionTrigger.displayName = 'AccordionTrigger';
|
||||
|
||||
/**
|
||||
* AccordionContent component
|
||||
*/
|
||||
export const AccordionContent = forwardRef<HTMLDivElement, AccordionContentProps>(
|
||||
({ children, className, ...props }, ref) => {
|
||||
const itemContext = useContext(AccordionItemContext);
|
||||
|
||||
if (!itemContext) {
|
||||
throw new Error('AccordionContent must be used within an AccordionItem');
|
||||
}
|
||||
|
||||
const { isExpanded } = itemContext;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="region"
|
||||
aria-hidden={!isExpanded}
|
||||
className={cn(accordionContentVariants({ expanded: isExpanded ? 'true' : 'false' }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div className={accordionContentInnerVariants({})}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AccordionContent.displayName = 'AccordionContent';
|
||||
126
packages/ui/src/Accordion/types.ts
Normal file
126
packages/ui/src/Accordion/types.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Accordion type - single or multiple items can be expanded
|
||||
*/
|
||||
export type AccordionType = 'single' | 'multiple';
|
||||
|
||||
/**
|
||||
* Accordion variant types
|
||||
*/
|
||||
export type AccordionVariant = 'default' | 'bordered' | 'separated';
|
||||
|
||||
/**
|
||||
* Accordion component props
|
||||
*/
|
||||
export interface AccordionProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Whether single or multiple items can be expanded
|
||||
* @default 'single'
|
||||
*/
|
||||
type?: AccordionType;
|
||||
|
||||
/**
|
||||
* Value of expanded item(s) - controlled mode
|
||||
* For single: string | undefined
|
||||
* For multiple: string[]
|
||||
*/
|
||||
value?: string | string[];
|
||||
|
||||
/**
|
||||
* Default expanded value(s) - uncontrolled mode
|
||||
*/
|
||||
defaultValue?: string | string[];
|
||||
|
||||
/**
|
||||
* Callback when expanded state changes
|
||||
*/
|
||||
onValueChange?: (value: string | string[]) => void;
|
||||
|
||||
/**
|
||||
* Whether to collapse others when opening an item (only for type="single")
|
||||
* @default true
|
||||
*/
|
||||
collapsible?: boolean;
|
||||
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: AccordionVariant;
|
||||
|
||||
/**
|
||||
* Accordion items
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AccordionItem component props
|
||||
*/
|
||||
export interface AccordionItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Unique value for this item
|
||||
*/
|
||||
value: string;
|
||||
|
||||
/**
|
||||
* Whether this item is disabled
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Item content (trigger + panel)
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AccordionTrigger component props
|
||||
*/
|
||||
export interface AccordionTriggerProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
/**
|
||||
* Icon to display (defaults to chevron)
|
||||
*/
|
||||
icon?: ReactNode;
|
||||
|
||||
/**
|
||||
* Trigger content
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AccordionContent component props
|
||||
*/
|
||||
export interface AccordionContentProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Content to display when expanded
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accordion ref type
|
||||
*/
|
||||
export type AccordionRef = HTMLDivElement;
|
||||
|
||||
/**
|
||||
* Internal accordion context
|
||||
*/
|
||||
export interface AccordionContextValue {
|
||||
type: AccordionType;
|
||||
value: string[];
|
||||
toggleItem: (itemValue: string) => void;
|
||||
variant: AccordionVariant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal accordion item context
|
||||
*/
|
||||
export interface AccordionItemContextValue {
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
136
packages/ui/src/Accordion/variants.ts
Normal file
136
packages/ui/src/Accordion/variants.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { createVariants } from '../system/variants';
|
||||
|
||||
/**
|
||||
* Accordion container variant definitions
|
||||
*/
|
||||
export const accordionVariants = createVariants({
|
||||
base: [
|
||||
'w-full',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
bordered: 'border border-border rounded-none',
|
||||
separated: 'space-y-2',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Accordion item variant definitions
|
||||
*/
|
||||
export const accordionItemVariants = createVariants({
|
||||
base: [
|
||||
'w-full',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-b border-border last:border-b-0',
|
||||
bordered: 'border-b border-border last:border-b-0',
|
||||
separated: 'border border-border rounded-none',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Accordion trigger variant definitions
|
||||
*/
|
||||
export const accordionTriggerVariants = createVariants({
|
||||
base: [
|
||||
// Layout
|
||||
'w-full',
|
||||
'flex items-center justify-between gap-4',
|
||||
'py-4 px-4',
|
||||
// Typography
|
||||
'font-mono text-sm font-medium',
|
||||
'text-foreground',
|
||||
'text-left',
|
||||
// Interaction
|
||||
'cursor-pointer',
|
||||
'transition-colors duration-150',
|
||||
// Focus
|
||||
'outline-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',
|
||||
// States
|
||||
'hover:bg-accent/5',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
disabled: {
|
||||
'true': 'opacity-50 cursor-not-allowed hover:bg-transparent',
|
||||
'false': '',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
disabled: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Accordion trigger icon variant definitions
|
||||
*/
|
||||
export const accordionTriggerIconVariants = createVariants({
|
||||
base: [
|
||||
'flex-shrink-0',
|
||||
'text-foreground-muted',
|
||||
'transition-transform duration-200',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
expanded: {
|
||||
'true': 'rotate-180',
|
||||
'false': 'rotate-0',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
expanded: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Accordion content variant definitions
|
||||
*/
|
||||
export const accordionContentVariants = createVariants({
|
||||
base: [
|
||||
'overflow-hidden',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
expanded: {
|
||||
'true': 'max-h-[1000px] opacity-100',
|
||||
'false': 'max-h-0 opacity-0',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
expanded: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Accordion content inner variant definitions
|
||||
*/
|
||||
export const accordionContentInnerVariants = createVariants({
|
||||
base: [
|
||||
'px-4 pb-4',
|
||||
'font-mono text-sm',
|
||||
'text-foreground-muted',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
|
@ -8,13 +8,13 @@ export const badgeVariants = createVariants({
|
|||
base: [
|
||||
// Layout
|
||||
'inline-flex items-center',
|
||||
// Typography
|
||||
'font-semibold',
|
||||
// Typography - Monospace, lowercase
|
||||
'font-mono font-medium lowercase',
|
||||
'whitespace-nowrap',
|
||||
// Borders & Radius
|
||||
'rounded-full',
|
||||
// Borders & Radius - SHARP CORNERS
|
||||
'rounded-none',
|
||||
// Transitions
|
||||
'transition-base',
|
||||
'transition-colors duration-150',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
|
|
|
|||
277
packages/ui/src/Breadcrumbs/Breadcrumbs.tsx
Normal file
277
packages/ui/src/Breadcrumbs/Breadcrumbs.tsx
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@tpmjs/utils/cn';
|
||||
import {
|
||||
Children,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Icon, type IconName } from '../Icon/Icon';
|
||||
import type {
|
||||
BreadcrumbEllipsisProps,
|
||||
BreadcrumbItemProps,
|
||||
BreadcrumbLinkProps,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbSeparatorProps,
|
||||
BreadcrumbsProps,
|
||||
} from './types';
|
||||
import {
|
||||
breadcrumbEllipsisVariants,
|
||||
breadcrumbIconVariants,
|
||||
breadcrumbItemVariants,
|
||||
breadcrumbLinkVariants,
|
||||
breadcrumbSeparatorVariants,
|
||||
breadcrumbsListVariants,
|
||||
breadcrumbsVariants,
|
||||
} from './variants';
|
||||
|
||||
/**
|
||||
* Get the separator icon based on type
|
||||
*/
|
||||
function getSeparatorIcon(separator: BreadcrumbSeparator): IconName {
|
||||
switch (separator) {
|
||||
case 'chevron':
|
||||
return 'chevronRight';
|
||||
case 'arrow':
|
||||
return 'arrowRight';
|
||||
case 'dot':
|
||||
return 'circle';
|
||||
case 'slash':
|
||||
default:
|
||||
return 'slash';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumbs component
|
||||
*
|
||||
* A navigation component that shows the user's location in a hierarchy.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Breadcrumbs, BreadcrumbItem } from '@tpmjs/ui/Breadcrumbs/Breadcrumbs';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return (
|
||||
* <Breadcrumbs>
|
||||
* <BreadcrumbItem href="/">Home</BreadcrumbItem>
|
||||
* <BreadcrumbItem href="/tools">Tools</BreadcrumbItem>
|
||||
* <BreadcrumbItem current>Current Tool</BreadcrumbItem>
|
||||
* </Breadcrumbs>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Breadcrumbs = forwardRef<HTMLElement, BreadcrumbsProps>(
|
||||
(
|
||||
{
|
||||
separator = 'slash',
|
||||
maxItems,
|
||||
itemsBeforeCollapse = 1,
|
||||
itemsAfterCollapse = 1,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const items = useMemo(() => {
|
||||
return Children.toArray(children).filter(isValidElement);
|
||||
}, [children]);
|
||||
|
||||
const shouldCollapse = maxItems && items.length > maxItems && !expanded;
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
if (!shouldCollapse) return items;
|
||||
|
||||
const start = items.slice(0, itemsBeforeCollapse);
|
||||
const end = items.slice(-itemsAfterCollapse);
|
||||
|
||||
return [...start, 'ellipsis', ...end];
|
||||
}, [items, shouldCollapse, itemsBeforeCollapse, itemsAfterCollapse]);
|
||||
|
||||
const renderSeparator = (key: string) => {
|
||||
if (typeof separator === 'string') {
|
||||
const iconName = getSeparatorIcon(separator as BreadcrumbSeparator);
|
||||
return (
|
||||
<BreadcrumbSeparatorComponent key={key}>
|
||||
<Icon icon={iconName} size="xs" />
|
||||
</BreadcrumbSeparatorComponent>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<BreadcrumbSeparatorComponent key={key}>
|
||||
{separator}
|
||||
</BreadcrumbSeparatorComponent>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={ref}
|
||||
aria-label="Breadcrumb"
|
||||
className={cn(breadcrumbsVariants({}), className)}
|
||||
{...props}
|
||||
>
|
||||
<ol className={breadcrumbsListVariants({})}>
|
||||
{visibleItems.map((item, index) => {
|
||||
if (item === 'ellipsis') {
|
||||
return (
|
||||
<li key="ellipsis" className={breadcrumbItemVariants({})}>
|
||||
{index > 0 && renderSeparator(`sep-before-ellipsis`)}
|
||||
<BreadcrumbEllipsis onClick={() => setExpanded(true)} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className={breadcrumbItemVariants({})}
|
||||
>
|
||||
{index > 0 && renderSeparator(`sep-${index}`)}
|
||||
{item}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Breadcrumbs.displayName = 'Breadcrumbs';
|
||||
|
||||
/**
|
||||
* BreadcrumbItem component
|
||||
*/
|
||||
export const BreadcrumbItem = forwardRef<HTMLSpanElement, BreadcrumbItemProps>(
|
||||
({ current = false, href, icon, children, className, ...props }, ref) => {
|
||||
const content = (
|
||||
<>
|
||||
{icon && <span className={breadcrumbIconVariants({})}>{icon}</span>}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
if (current) {
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
aria-current="page"
|
||||
className={cn(breadcrumbLinkVariants({ current: 'true' }), className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={cn(breadcrumbLinkVariants({ current: 'false' }), className)}
|
||||
{...(props as any)}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(breadcrumbLinkVariants({ current: 'false' }), className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
BreadcrumbItem.displayName = 'BreadcrumbItem';
|
||||
|
||||
/**
|
||||
* BreadcrumbLink component
|
||||
*/
|
||||
export const BreadcrumbLink = forwardRef<HTMLAnchorElement, BreadcrumbLinkProps>(
|
||||
({ href, children, className, ...props }, ref) => (
|
||||
<a
|
||||
ref={ref}
|
||||
href={href}
|
||||
className={cn(breadcrumbLinkVariants({ current: 'false' }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
);
|
||||
|
||||
BreadcrumbLink.displayName = 'BreadcrumbLink';
|
||||
|
||||
/**
|
||||
* BreadcrumbSeparator component (internal)
|
||||
*/
|
||||
const BreadcrumbSeparatorComponent = forwardRef<HTMLSpanElement, BreadcrumbSeparatorProps>(
|
||||
({ children, className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn(breadcrumbSeparatorVariants({}), className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? '/'}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
|
||||
BreadcrumbSeparatorComponent.displayName = 'BreadcrumbSeparator';
|
||||
|
||||
// Export as BreadcrumbSeparator
|
||||
export { BreadcrumbSeparatorComponent as BreadcrumbSeparator };
|
||||
|
||||
/**
|
||||
* BreadcrumbEllipsis component
|
||||
*/
|
||||
export const BreadcrumbEllipsis = forwardRef<HTMLSpanElement, BreadcrumbEllipsisProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="button"
|
||||
aria-label="Show more breadcrumbs"
|
||||
className={cn(breadcrumbEllipsisVariants({}), className)}
|
||||
{...props}
|
||||
>
|
||||
<Icon icon="moreHorizontal" size="sm" />
|
||||
</span>
|
||||
)
|
||||
);
|
||||
|
||||
BreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis';
|
||||
|
||||
/**
|
||||
* BreadcrumbPage component
|
||||
*
|
||||
* Represents the current page in breadcrumbs (non-link, just text)
|
||||
*/
|
||||
export const BreadcrumbPage = forwardRef<HTMLSpanElement, Omit<BreadcrumbItemProps, 'href' | 'current'>>(
|
||||
({ children, className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
aria-current="page"
|
||||
className={cn(breadcrumbLinkVariants({ current: 'true' }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
|
||||
BreadcrumbPage.displayName = 'BreadcrumbPage';
|
||||
101
packages/ui/src/Breadcrumbs/types.ts
Normal file
101
packages/ui/src/Breadcrumbs/types.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Breadcrumb separator types
|
||||
*/
|
||||
export type BreadcrumbSeparator = 'slash' | 'chevron' | 'arrow' | 'dot';
|
||||
|
||||
/**
|
||||
* Breadcrumbs component props
|
||||
*/
|
||||
export interface BreadcrumbsProps extends HTMLAttributes<HTMLElement> {
|
||||
/**
|
||||
* Separator between items
|
||||
* @default 'slash'
|
||||
*/
|
||||
separator?: BreadcrumbSeparator | ReactNode;
|
||||
|
||||
/**
|
||||
* Maximum number of items to show before collapsing
|
||||
* @default undefined (no collapse)
|
||||
*/
|
||||
maxItems?: number;
|
||||
|
||||
/**
|
||||
* Number of items to show at start when collapsed
|
||||
* @default 1
|
||||
*/
|
||||
itemsBeforeCollapse?: number;
|
||||
|
||||
/**
|
||||
* Number of items to show at end when collapsed
|
||||
* @default 1
|
||||
*/
|
||||
itemsAfterCollapse?: number;
|
||||
|
||||
/**
|
||||
* Breadcrumb items
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* BreadcrumbItem component props
|
||||
*/
|
||||
export interface BreadcrumbItemProps extends HTMLAttributes<HTMLLIElement> {
|
||||
/**
|
||||
* Whether this is the current/active page
|
||||
* @default false
|
||||
*/
|
||||
current?: boolean;
|
||||
|
||||
/**
|
||||
* Link href (if not current)
|
||||
*/
|
||||
href?: string;
|
||||
|
||||
/**
|
||||
* Icon to display before text
|
||||
*/
|
||||
icon?: ReactNode;
|
||||
|
||||
/**
|
||||
* Item content
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* BreadcrumbLink component props
|
||||
*/
|
||||
export interface BreadcrumbLinkProps extends HTMLAttributes<HTMLAnchorElement> {
|
||||
/**
|
||||
* Link href
|
||||
*/
|
||||
href: string;
|
||||
|
||||
/**
|
||||
* Link content
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* BreadcrumbSeparator component props
|
||||
*/
|
||||
export interface BreadcrumbSeparatorProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
/**
|
||||
* Custom separator content
|
||||
*/
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* BreadcrumbEllipsis component props
|
||||
*/
|
||||
export interface BreadcrumbEllipsisProps extends HTMLAttributes<HTMLSpanElement> {}
|
||||
|
||||
/**
|
||||
* Breadcrumbs ref type
|
||||
*/
|
||||
export type BreadcrumbsRef = HTMLElement;
|
||||
125
packages/ui/src/Breadcrumbs/variants.ts
Normal file
125
packages/ui/src/Breadcrumbs/variants.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { createVariants } from '../system/variants';
|
||||
|
||||
/**
|
||||
* Breadcrumbs container variant definitions
|
||||
*/
|
||||
export const breadcrumbsVariants = createVariants({
|
||||
base: [
|
||||
'flex items-center',
|
||||
'font-mono text-sm',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Breadcrumbs list variant definitions
|
||||
*/
|
||||
export const breadcrumbsListVariants = createVariants({
|
||||
base: [
|
||||
'flex items-center gap-1',
|
||||
'flex-wrap',
|
||||
'list-none',
|
||||
'm-0 p-0',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Breadcrumb item variant definitions
|
||||
*/
|
||||
export const breadcrumbItemVariants = createVariants({
|
||||
base: [
|
||||
'flex items-center gap-1',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
current: {
|
||||
'true': '',
|
||||
'false': '',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
current: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Breadcrumb link variant definitions
|
||||
*/
|
||||
export const breadcrumbLinkVariants = createVariants({
|
||||
base: [
|
||||
'text-foreground-muted',
|
||||
'hover:text-foreground',
|
||||
'transition-colors duration-150',
|
||||
'outline-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
||||
'lowercase',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
current: {
|
||||
'true': 'text-foreground font-medium pointer-events-none',
|
||||
'false': 'cursor-pointer',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
current: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Breadcrumb separator variant definitions
|
||||
*/
|
||||
export const breadcrumbSeparatorVariants = createVariants({
|
||||
base: [
|
||||
'mx-1',
|
||||
'text-foreground-muted',
|
||||
'select-none',
|
||||
'opacity-60',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Breadcrumb ellipsis variant definitions
|
||||
*/
|
||||
export const breadcrumbEllipsisVariants = createVariants({
|
||||
base: [
|
||||
'flex items-center justify-center',
|
||||
'w-6 h-6',
|
||||
'text-foreground-muted',
|
||||
'cursor-pointer',
|
||||
'hover:text-foreground',
|
||||
'transition-colors duration-150',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Breadcrumb icon variant definitions
|
||||
*/
|
||||
export const breadcrumbIconVariants = createVariants({
|
||||
base: [
|
||||
'flex-shrink-0',
|
||||
'w-4 h-4',
|
||||
'mr-1',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
|
@ -8,15 +8,15 @@ 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',
|
||||
// Typography - Monospace for buttons
|
||||
'font-mono font-medium text-sm',
|
||||
'whitespace-nowrap lowercase',
|
||||
// Borders & Radius - SHARP CORNERS
|
||||
'rounded-none',
|
||||
// Transitions - Fast, subtle
|
||||
'transition-colors duration-150',
|
||||
// Focus - Copper accent
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
||||
// Disabled state
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
].join(' '),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
|||
* Visual variant of the card
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: 'default' | 'elevated' | 'outline' | 'blueprint' | 'ghost' | 'brutalist';
|
||||
variant?: 'default' | 'elevated' | 'outline' | 'blueprint' | 'ghost' | 'featured' | 'brutalist';
|
||||
|
||||
/**
|
||||
* Padding size for the card
|
||||
|
|
|
|||
|
|
@ -8,37 +8,38 @@ export const cardVariants = createVariants({
|
|||
base: [
|
||||
// Layout
|
||||
'relative',
|
||||
// Borders & Radius
|
||||
'rounded-lg',
|
||||
// Borders & Radius - SHARP CORNERS
|
||||
'rounded-none',
|
||||
// Transitions
|
||||
'transition-base',
|
||||
'transition-colors duration-150',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
'border border-dotted border-border',
|
||||
'border border-dashed border-border',
|
||||
'bg-card text-card-foreground',
|
||||
'shadow-sm',
|
||||
].join(' '),
|
||||
|
||||
elevated: [
|
||||
'border border-dotted border-border',
|
||||
'border border-dashed border-border',
|
||||
'bg-surface-elevated text-card-foreground',
|
||||
'shadow-md',
|
||||
].join(' '),
|
||||
|
||||
outline: ['border-2 border-dotted border-border', 'bg-transparent text-foreground'].join(' '),
|
||||
outline: ['border-2 border-dashed border-border', 'bg-transparent text-foreground'].join(' '),
|
||||
|
||||
blueprint: [
|
||||
'border border-dotted border-border',
|
||||
'border border-dashed border-border',
|
||||
'bg-card text-card-foreground',
|
||||
'shadow-blueprint',
|
||||
'hover:shadow-blueprint-hover',
|
||||
].join(' '),
|
||||
|
||||
ghost: ['bg-transparent text-foreground'].join(' '),
|
||||
|
||||
featured: [
|
||||
'border-2 border-solid border-foreground',
|
||||
'bg-card text-card-foreground',
|
||||
].join(' '),
|
||||
|
||||
brutalist: [
|
||||
'border-[6px] border-foreground',
|
||||
'bg-background text-foreground',
|
||||
|
|
|
|||
|
|
@ -41,15 +41,15 @@ export const checkboxUIVariants = createVariants({
|
|||
base: [
|
||||
// Layout
|
||||
'relative inline-flex items-center justify-center flex-shrink-0',
|
||||
// Border & Background
|
||||
'rounded-sm border-2 border-border',
|
||||
// Border & Background - SHARP CORNERS
|
||||
'rounded-none border-2 border-border',
|
||||
'bg-background',
|
||||
// Transitions
|
||||
'transition-all duration-200',
|
||||
// Transitions - Fast, subtle
|
||||
'transition-colors duration-150',
|
||||
// Hover state
|
||||
'peer-hover:border-border-strong',
|
||||
// Focus state (via peer)
|
||||
'peer-focus-visible:ring-2 peer-focus-visible:ring-primary/20 peer-focus-visible:ring-offset-2',
|
||||
// Focus state (via peer) - Copper accent
|
||||
'peer-focus-visible:ring-2 peer-focus-visible:ring-primary peer-focus-visible:ring-offset-2',
|
||||
// Checked state
|
||||
'peer-checked:bg-primary peer-checked:border-primary',
|
||||
// Indeterminate state (custom data attribute)
|
||||
|
|
|
|||
303
packages/ui/src/Drawer/Drawer.tsx
Normal file
303
packages/ui/src/Drawer/Drawer.tsx
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@tpmjs/utils/cn';
|
||||
import { forwardRef, useCallback, useEffect, useId, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import type {
|
||||
DrawerBodyProps,
|
||||
DrawerFooterProps,
|
||||
DrawerHeaderProps,
|
||||
DrawerProps,
|
||||
} from './types';
|
||||
import {
|
||||
drawerBackdropVariants,
|
||||
drawerBodyVariants,
|
||||
drawerCloseButtonVariants,
|
||||
drawerContainerVariants,
|
||||
drawerFooterVariants,
|
||||
drawerHeaderVariants,
|
||||
drawerPanelVariants,
|
||||
drawerTitleVariants,
|
||||
} from './variants';
|
||||
|
||||
/**
|
||||
* Drawer component
|
||||
*
|
||||
* A slide-out panel overlay with focus trap, keyboard support, and accessibility features.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Drawer } from '@tpmjs/ui/Drawer/Drawer';
|
||||
* import { Button } from '@tpmjs/ui/Button/Button';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const [open, setOpen] = useState(false);
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <Button onClick={() => setOpen(true)}>Open Drawer</Button>
|
||||
* <Drawer
|
||||
* open={open}
|
||||
* onClose={() => setOpen(false)}
|
||||
* title="Settings"
|
||||
* side="right"
|
||||
* footer={
|
||||
* <>
|
||||
* <Button variant="outline" onClick={() => setOpen(false)}>
|
||||
* Cancel
|
||||
* </Button>
|
||||
* <Button onClick={() => setOpen(false)}>
|
||||
* Save
|
||||
* </Button>
|
||||
* </>
|
||||
* }
|
||||
* >
|
||||
* <p>Drawer content goes here.</p>
|
||||
* </Drawer>
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(
|
||||
(
|
||||
{
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
side = 'right',
|
||||
size = 'md',
|
||||
closeOnBackdropClick = true,
|
||||
closeOnEscape = true,
|
||||
showCloseButton = true,
|
||||
footer,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const previousActiveElement = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!open || !closeOnEscape) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, closeOnEscape, onClose]);
|
||||
|
||||
// Handle focus trap
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Store the previously focused element
|
||||
previousActiveElement.current = document.activeElement as HTMLElement;
|
||||
|
||||
// Focus the panel
|
||||
const timer = setTimeout(() => {
|
||||
panelRef.current?.focus();
|
||||
}, 0);
|
||||
|
||||
// Prevent body scroll
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.body.style.overflow = originalOverflow;
|
||||
|
||||
// Restore focus to the previously focused element
|
||||
previousActiveElement.current?.focus();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Handle backdrop click
|
||||
const handleBackdropClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
if (closeOnBackdropClick && event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[closeOnBackdropClick, onClose]
|
||||
);
|
||||
|
||||
// Handle focus trap within drawer
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
|
||||
const focusableElements = panel.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (event.shiftKey && document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement?.focus();
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Only render in browser (for SSR compatibility)
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const drawerContent = (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={drawerBackdropVariants({ state: 'entered' })}
|
||||
aria-hidden="true"
|
||||
onClick={handleBackdropClick}
|
||||
/>
|
||||
|
||||
{/* Container */}
|
||||
<div
|
||||
className={drawerContainerVariants({})}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Panel */}
|
||||
<div
|
||||
ref={(node) => {
|
||||
// Handle both refs
|
||||
(panelRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
if (typeof ref === 'function') {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
ref.current = node;
|
||||
}
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
aria-describedby={description ? descriptionId : undefined}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
drawerPanelVariants({ side, size, state: 'entered' }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className={drawerHeaderVariants({})}>
|
||||
{title && (
|
||||
<h2 id={titleId} className={drawerTitleVariants({})}>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={drawerCloseButtonVariants({})}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<Icon icon="x" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hidden description for screen readers */}
|
||||
{description && (
|
||||
<p id={descriptionId} className="sr-only">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className={drawerBodyVariants({})}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className={drawerFooterVariants({})}>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return createPortal(drawerContent, document.body);
|
||||
}
|
||||
);
|
||||
|
||||
Drawer.displayName = 'Drawer';
|
||||
|
||||
/**
|
||||
* DrawerHeader component for custom headers
|
||||
*/
|
||||
export const DrawerHeader = forwardRef<HTMLDivElement, DrawerHeaderProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(drawerHeaderVariants({}), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
DrawerHeader.displayName = 'DrawerHeader';
|
||||
|
||||
/**
|
||||
* DrawerBody component for custom body content
|
||||
*/
|
||||
export const DrawerBody = forwardRef<HTMLDivElement, DrawerBodyProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(drawerBodyVariants({}), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
DrawerBody.displayName = 'DrawerBody';
|
||||
|
||||
/**
|
||||
* DrawerFooter component for custom footers
|
||||
*/
|
||||
export const DrawerFooter = forwardRef<HTMLDivElement, DrawerFooterProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(drawerFooterVariants({}), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
DrawerFooter.displayName = 'DrawerFooter';
|
||||
102
packages/ui/src/Drawer/types.ts
Normal file
102
packages/ui/src/Drawer/types.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Drawer side types
|
||||
*/
|
||||
export type DrawerSide = 'left' | 'right' | 'top' | 'bottom';
|
||||
|
||||
/**
|
||||
* Drawer size types
|
||||
*/
|
||||
export type DrawerSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
|
||||
/**
|
||||
* Drawer component props
|
||||
*/
|
||||
export interface DrawerProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
/**
|
||||
* Whether the drawer is open
|
||||
*/
|
||||
open: boolean;
|
||||
|
||||
/**
|
||||
* Callback when the drawer should close
|
||||
*/
|
||||
onClose: () => void;
|
||||
|
||||
/**
|
||||
* Which side the drawer slides in from
|
||||
* @default 'right'
|
||||
*/
|
||||
side?: DrawerSide;
|
||||
|
||||
/**
|
||||
* Size of the drawer
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: DrawerSize;
|
||||
|
||||
/**
|
||||
* Drawer title (displayed in header)
|
||||
*/
|
||||
title?: ReactNode;
|
||||
|
||||
/**
|
||||
* Drawer description (for accessibility)
|
||||
*/
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* Whether to close on backdrop click
|
||||
* @default true
|
||||
*/
|
||||
closeOnBackdropClick?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to close on Escape key
|
||||
* @default true
|
||||
*/
|
||||
closeOnEscape?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to show the close button
|
||||
* @default true
|
||||
*/
|
||||
showCloseButton?: boolean;
|
||||
|
||||
/**
|
||||
* Footer content (buttons, actions)
|
||||
*/
|
||||
footer?: ReactNode;
|
||||
|
||||
/**
|
||||
* Drawer content
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* DrawerHeader component props
|
||||
*/
|
||||
export interface DrawerHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* DrawerBody component props
|
||||
*/
|
||||
export interface DrawerBodyProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* DrawerFooter component props
|
||||
*/
|
||||
export interface DrawerFooterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drawer ref type
|
||||
*/
|
||||
export type DrawerRef = HTMLDivElement;
|
||||
203
packages/ui/src/Drawer/variants.ts
Normal file
203
packages/ui/src/Drawer/variants.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import { createVariants } from '../system/variants';
|
||||
|
||||
/**
|
||||
* Drawer backdrop variant definitions
|
||||
*/
|
||||
export const drawerBackdropVariants = createVariants({
|
||||
base: [
|
||||
'fixed inset-0',
|
||||
'bg-foreground/80',
|
||||
'backdrop-blur-sm',
|
||||
'z-[var(--z-drawer)]',
|
||||
'transition-opacity duration-200',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
state: {
|
||||
entering: 'opacity-0',
|
||||
entered: 'opacity-100',
|
||||
exiting: 'opacity-0',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
state: 'entered',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Drawer container variant definitions
|
||||
*/
|
||||
export const drawerContainerVariants = createVariants({
|
||||
base: [
|
||||
'fixed inset-0',
|
||||
'z-[var(--z-drawer)]',
|
||||
'overflow-hidden',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Drawer panel variant definitions
|
||||
*/
|
||||
export const drawerPanelVariants = createVariants({
|
||||
base: [
|
||||
// Layout
|
||||
'fixed',
|
||||
'flex flex-col',
|
||||
// Styling - Sharp corners, blueprint aesthetic
|
||||
'bg-surface border-border',
|
||||
// Shadow
|
||||
'shadow-xl',
|
||||
// Animation
|
||||
'transition-transform duration-200 ease-out',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
side: {
|
||||
left: 'inset-y-0 left-0 border-r',
|
||||
right: 'inset-y-0 right-0 border-l',
|
||||
top: 'inset-x-0 top-0 border-b',
|
||||
bottom: 'inset-x-0 bottom-0 border-t',
|
||||
},
|
||||
size: {
|
||||
sm: '',
|
||||
md: '',
|
||||
lg: '',
|
||||
xl: '',
|
||||
full: '',
|
||||
},
|
||||
state: {
|
||||
entering: '',
|
||||
entered: 'translate-x-0 translate-y-0',
|
||||
exiting: '',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
side: 'right',
|
||||
size: 'md',
|
||||
state: 'entered',
|
||||
},
|
||||
|
||||
compoundVariants: [
|
||||
// Size + Side combinations for horizontal drawers (left/right)
|
||||
{ conditions: { side: 'left', size: 'sm' }, className: 'w-64 max-w-[80vw]' },
|
||||
{ conditions: { side: 'left', size: 'md' }, className: 'w-80 max-w-[80vw]' },
|
||||
{ conditions: { side: 'left', size: 'lg' }, className: 'w-96 max-w-[80vw]' },
|
||||
{ conditions: { side: 'left', size: 'xl' }, className: 'w-[32rem] max-w-[80vw]' },
|
||||
{ conditions: { side: 'left', size: 'full' }, className: 'w-screen' },
|
||||
{ conditions: { side: 'right', size: 'sm' }, className: 'w-64 max-w-[80vw]' },
|
||||
{ conditions: { side: 'right', size: 'md' }, className: 'w-80 max-w-[80vw]' },
|
||||
{ conditions: { side: 'right', size: 'lg' }, className: 'w-96 max-w-[80vw]' },
|
||||
{ conditions: { side: 'right', size: 'xl' }, className: 'w-[32rem] max-w-[80vw]' },
|
||||
{ conditions: { side: 'right', size: 'full' }, className: 'w-screen' },
|
||||
|
||||
// Size + Side combinations for vertical drawers (top/bottom)
|
||||
{ conditions: { side: 'top', size: 'sm' }, className: 'h-48 max-h-[50vh]' },
|
||||
{ conditions: { side: 'top', size: 'md' }, className: 'h-64 max-h-[50vh]' },
|
||||
{ conditions: { side: 'top', size: 'lg' }, className: 'h-80 max-h-[50vh]' },
|
||||
{ conditions: { side: 'top', size: 'xl' }, className: 'h-96 max-h-[50vh]' },
|
||||
{ conditions: { side: 'top', size: 'full' }, className: 'h-screen' },
|
||||
{ conditions: { side: 'bottom', size: 'sm' }, className: 'h-48 max-h-[50vh]' },
|
||||
{ conditions: { side: 'bottom', size: 'md' }, className: 'h-64 max-h-[50vh]' },
|
||||
{ conditions: { side: 'bottom', size: 'lg' }, className: 'h-80 max-h-[50vh]' },
|
||||
{ conditions: { side: 'bottom', size: 'xl' }, className: 'h-96 max-h-[50vh]' },
|
||||
{ conditions: { side: 'bottom', size: 'full' }, className: 'h-screen' },
|
||||
|
||||
// State + Side combinations for enter/exit transforms
|
||||
{ conditions: { side: 'left', state: 'entering' }, className: '-translate-x-full' },
|
||||
{ conditions: { side: 'left', state: 'exiting' }, className: '-translate-x-full' },
|
||||
{ conditions: { side: 'right', state: 'entering' }, className: 'translate-x-full' },
|
||||
{ conditions: { side: 'right', state: 'exiting' }, className: 'translate-x-full' },
|
||||
{ conditions: { side: 'top', state: 'entering' }, className: '-translate-y-full' },
|
||||
{ conditions: { side: 'top', state: 'exiting' }, className: '-translate-y-full' },
|
||||
{ conditions: { side: 'bottom', state: 'entering' }, className: 'translate-y-full' },
|
||||
{ conditions: { side: 'bottom', state: 'exiting' }, className: 'translate-y-full' },
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Drawer header variant definitions
|
||||
*/
|
||||
export const drawerHeaderVariants = createVariants({
|
||||
base: [
|
||||
'flex items-center justify-between',
|
||||
'px-6 py-4',
|
||||
'border-b border-border',
|
||||
'bg-surface',
|
||||
'flex-shrink-0',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Drawer body variant definitions
|
||||
*/
|
||||
export const drawerBodyVariants = createVariants({
|
||||
base: [
|
||||
'flex-1',
|
||||
'px-6 py-4',
|
||||
'overflow-y-auto',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Drawer footer variant definitions
|
||||
*/
|
||||
export const drawerFooterVariants = createVariants({
|
||||
base: [
|
||||
'flex items-center justify-end gap-3',
|
||||
'px-6 py-4',
|
||||
'border-t border-border',
|
||||
'bg-surface',
|
||||
'flex-shrink-0',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Drawer title variant definitions
|
||||
*/
|
||||
export const drawerTitleVariants = createVariants({
|
||||
base: [
|
||||
'font-mono font-semibold text-lg',
|
||||
'text-foreground',
|
||||
'lowercase',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Drawer close button variant definitions
|
||||
*/
|
||||
export const drawerCloseButtonVariants = createVariants({
|
||||
base: [
|
||||
'p-2 -m-2',
|
||||
'text-foreground-muted',
|
||||
'hover:text-foreground hover:bg-accent/10',
|
||||
'transition-colors duration-150',
|
||||
'rounded-none',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
456
packages/ui/src/DropdownMenu/DropdownMenu.tsx
Normal file
456
packages/ui/src/DropdownMenu/DropdownMenu.tsx
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@tpmjs/utils/cn';
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type {
|
||||
DropdownMenuGroupProps,
|
||||
DropdownMenuItemProps,
|
||||
DropdownMenuLabelProps,
|
||||
DropdownMenuPlacement,
|
||||
DropdownMenuProps,
|
||||
DropdownMenuSeparatorProps,
|
||||
} from './types';
|
||||
import {
|
||||
dropdownMenuContentVariants,
|
||||
dropdownMenuItemIconVariants,
|
||||
dropdownMenuItemShortcutVariants,
|
||||
dropdownMenuItemVariants,
|
||||
dropdownMenuLabelVariants,
|
||||
dropdownMenuSeparatorVariants,
|
||||
} from './variants';
|
||||
|
||||
// Context for menu state
|
||||
interface DropdownMenuContextValue {
|
||||
closeMenu: () => void;
|
||||
closeOnSelect: boolean;
|
||||
activeIndex: number;
|
||||
setActiveIndex: (index: number) => void;
|
||||
registerItem: () => number;
|
||||
}
|
||||
|
||||
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Calculate position based on trigger and placement
|
||||
*/
|
||||
function calculatePosition(
|
||||
triggerRect: DOMRect,
|
||||
contentRect: DOMRect,
|
||||
placement: DropdownMenuPlacement,
|
||||
offset: number
|
||||
): { top: number; left: number } {
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
switch (placement) {
|
||||
case 'bottom':
|
||||
top = triggerRect.bottom + scrollY + offset;
|
||||
left = triggerRect.left + scrollX + (triggerRect.width - contentRect.width) / 2;
|
||||
break;
|
||||
case 'bottom-start':
|
||||
top = triggerRect.bottom + scrollY + offset;
|
||||
left = triggerRect.left + scrollX;
|
||||
break;
|
||||
case 'bottom-end':
|
||||
top = triggerRect.bottom + scrollY + offset;
|
||||
left = triggerRect.right + scrollX - contentRect.width;
|
||||
break;
|
||||
case 'top':
|
||||
top = triggerRect.top + scrollY - contentRect.height - offset;
|
||||
left = triggerRect.left + scrollX + (triggerRect.width - contentRect.width) / 2;
|
||||
break;
|
||||
case 'top-start':
|
||||
top = triggerRect.top + scrollY - contentRect.height - offset;
|
||||
left = triggerRect.left + scrollX;
|
||||
break;
|
||||
case 'top-end':
|
||||
top = triggerRect.top + scrollY - contentRect.height - offset;
|
||||
left = triggerRect.right + scrollX - contentRect.width;
|
||||
break;
|
||||
}
|
||||
|
||||
return { top, left };
|
||||
}
|
||||
|
||||
/**
|
||||
* DropdownMenu component
|
||||
*
|
||||
* A menu that appears when clicking a trigger element.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import {
|
||||
* DropdownMenu,
|
||||
* DropdownMenuItem,
|
||||
* DropdownMenuSeparator,
|
||||
* } from '@tpmjs/ui/DropdownMenu/DropdownMenu';
|
||||
* import { Button } from '@tpmjs/ui/Button/Button';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return (
|
||||
* <DropdownMenu trigger={<Button>Open Menu</Button>}>
|
||||
* <DropdownMenuItem onSelect={() => console.log('Edit')}>
|
||||
* Edit
|
||||
* </DropdownMenuItem>
|
||||
* <DropdownMenuItem onSelect={() => console.log('Duplicate')}>
|
||||
* Duplicate
|
||||
* </DropdownMenuItem>
|
||||
* <DropdownMenuSeparator />
|
||||
* <DropdownMenuItem destructive onSelect={() => console.log('Delete')}>
|
||||
* Delete
|
||||
* </DropdownMenuItem>
|
||||
* </DropdownMenu>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const DropdownMenu = forwardRef<HTMLDivElement, DropdownMenuProps>(
|
||||
(
|
||||
{
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
defaultOpen = false,
|
||||
trigger,
|
||||
placement = 'bottom-start',
|
||||
offset = 4,
|
||||
closeOnClickOutside = true,
|
||||
closeOnEscape = true,
|
||||
closeOnSelect = true,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const isOpen = isControlled ? controlledOpen : internalOpen;
|
||||
|
||||
const triggerRef = useRef<HTMLElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const itemCountRef = useRef(0);
|
||||
|
||||
const setOpen = useCallback(
|
||||
(value: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(value);
|
||||
}
|
||||
onOpenChange?.(value);
|
||||
if (!value) {
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
},
|
||||
[isControlled, onOpenChange]
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setOpen(!isOpen);
|
||||
}, [isOpen, setOpen]);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
const registerItem = useCallback(() => {
|
||||
const index = itemCountRef.current;
|
||||
itemCountRef.current += 1;
|
||||
return index;
|
||||
}, []);
|
||||
|
||||
// Reset item count when menu closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
itemCountRef.current = 0;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Update position when open
|
||||
useEffect(() => {
|
||||
if (!isOpen || !triggerRef.current || !contentRef.current) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
const triggerRect = triggerRef.current!.getBoundingClientRect();
|
||||
const contentRect = contentRef.current!.getBoundingClientRect();
|
||||
const newPosition = calculatePosition(triggerRect, contentRect, placement, offset);
|
||||
setPosition(newPosition);
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, [isOpen, placement, offset]);
|
||||
|
||||
// Handle click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen || !closeOnClickOutside) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
triggerRef.current?.contains(target) ||
|
||||
contentRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, closeOnClickOutside, closeMenu]);
|
||||
|
||||
// Handle escape key and keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
if (closeOnEscape) {
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
}
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setActiveIndex((prev) =>
|
||||
prev < itemCountRef.current - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setActiveIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : itemCountRef.current - 1
|
||||
);
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
setActiveIndex(0);
|
||||
break;
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
setActiveIndex(itemCountRef.current - 1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, closeOnEscape, closeMenu]);
|
||||
|
||||
// Clone trigger element with click handler
|
||||
const triggerElement = isValidElement(trigger)
|
||||
? cloneElement(trigger as React.ReactElement<any>, {
|
||||
ref: triggerRef,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
(trigger as React.ReactElement<any>).props.onClick?.(e);
|
||||
handleToggle();
|
||||
},
|
||||
'aria-haspopup': 'menu',
|
||||
'aria-expanded': isOpen,
|
||||
})
|
||||
: trigger;
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
closeMenu,
|
||||
closeOnSelect,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
registerItem,
|
||||
}),
|
||||
[closeMenu, closeOnSelect, activeIndex, registerItem]
|
||||
);
|
||||
|
||||
// Only render portal in browser
|
||||
const canRenderPortal = typeof window !== 'undefined';
|
||||
|
||||
return (
|
||||
<DropdownMenuContext.Provider value={contextValue}>
|
||||
{triggerElement}
|
||||
{canRenderPortal &&
|
||||
isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={(node) => {
|
||||
(contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
if (typeof ref === 'function') {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
ref.current = node;
|
||||
}
|
||||
}}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
className={cn(dropdownMenuContentVariants({ state: 'entered' }), className)}
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</DropdownMenuContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DropdownMenu.displayName = 'DropdownMenu';
|
||||
|
||||
/**
|
||||
* DropdownMenuItem component
|
||||
*/
|
||||
export const DropdownMenuItem = forwardRef<HTMLButtonElement, DropdownMenuItemProps>(
|
||||
(
|
||||
{
|
||||
disabled = false,
|
||||
destructive = false,
|
||||
icon,
|
||||
shortcut,
|
||||
onSelect,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const context = useContext(DropdownMenuContext);
|
||||
const indexRef = useRef<number>(-1);
|
||||
|
||||
// Register this item and get its index
|
||||
useEffect(() => {
|
||||
if (context && indexRef.current === -1) {
|
||||
indexRef.current = context.registerItem();
|
||||
}
|
||||
}, [context]);
|
||||
|
||||
const isActive = context?.activeIndex === indexRef.current;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (disabled) return;
|
||||
onSelect?.();
|
||||
if (context?.closeOnSelect) {
|
||||
context.closeMenu();
|
||||
}
|
||||
}, [disabled, onSelect, context]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
},
|
||||
[handleClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className={cn(
|
||||
dropdownMenuItemVariants({
|
||||
disabled: disabled ? 'true' : 'false',
|
||||
destructive: destructive ? 'true' : 'false',
|
||||
active: isActive ? 'true' : 'false',
|
||||
}),
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseEnter={() => context?.setActiveIndex(indexRef.current)}
|
||||
{...props}
|
||||
>
|
||||
{icon && (
|
||||
<span className={dropdownMenuItemIconVariants({ destructive: destructive ? 'true' : 'false' })}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1">{children}</span>
|
||||
{shortcut && (
|
||||
<span className={dropdownMenuItemShortcutVariants({})}>
|
||||
{shortcut}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DropdownMenuItem.displayName = 'DropdownMenuItem';
|
||||
|
||||
/**
|
||||
* DropdownMenuSeparator component
|
||||
*/
|
||||
export const DropdownMenuSeparator = forwardRef<HTMLDivElement, DropdownMenuSeparatorProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="separator"
|
||||
className={cn(dropdownMenuSeparatorVariants({}), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
|
||||
|
||||
/**
|
||||
* DropdownMenuLabel component
|
||||
*/
|
||||
export const DropdownMenuLabel = forwardRef<HTMLDivElement, DropdownMenuLabelProps>(
|
||||
({ children, className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(dropdownMenuLabelVariants({}), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
DropdownMenuLabel.displayName = 'DropdownMenuLabel';
|
||||
|
||||
/**
|
||||
* DropdownMenuGroup component
|
||||
*/
|
||||
export const DropdownMenuGroup = forwardRef<HTMLDivElement, DropdownMenuGroupProps>(
|
||||
({ label, children, className, ...props }, ref) => (
|
||||
<div ref={ref} role="group" className={className} {...props}>
|
||||
{label && <DropdownMenuLabel>{label}</DropdownMenuLabel>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
DropdownMenuGroup.displayName = 'DropdownMenuGroup';
|
||||
142
packages/ui/src/DropdownMenu/types.ts
Normal file
142
packages/ui/src/DropdownMenu/types.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Dropdown menu placement types
|
||||
*/
|
||||
export type DropdownMenuPlacement =
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'top'
|
||||
| 'top-start'
|
||||
| 'top-end';
|
||||
|
||||
/**
|
||||
* DropdownMenu component props
|
||||
*/
|
||||
export interface DropdownMenuProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Whether the menu is open (controlled mode)
|
||||
*/
|
||||
open?: boolean;
|
||||
|
||||
/**
|
||||
* Callback when open state changes
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
|
||||
/**
|
||||
* Default open state (uncontrolled mode)
|
||||
* @default false
|
||||
*/
|
||||
defaultOpen?: boolean;
|
||||
|
||||
/**
|
||||
* The trigger element
|
||||
*/
|
||||
trigger: ReactNode;
|
||||
|
||||
/**
|
||||
* Placement of the menu relative to trigger
|
||||
* @default 'bottom-start'
|
||||
*/
|
||||
placement?: DropdownMenuPlacement;
|
||||
|
||||
/**
|
||||
* Offset from the trigger element in pixels
|
||||
* @default 4
|
||||
*/
|
||||
offset?: number;
|
||||
|
||||
/**
|
||||
* Whether to close when clicking outside
|
||||
* @default true
|
||||
*/
|
||||
closeOnClickOutside?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to close on Escape key
|
||||
* @default true
|
||||
*/
|
||||
closeOnEscape?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to close when an item is selected
|
||||
* @default true
|
||||
*/
|
||||
closeOnSelect?: boolean;
|
||||
|
||||
/**
|
||||
* Menu items
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* DropdownMenuItem component props
|
||||
*/
|
||||
export interface DropdownMenuItemProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
/**
|
||||
* Whether the item is disabled
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the item is destructive (red styling)
|
||||
* @default false
|
||||
*/
|
||||
destructive?: boolean;
|
||||
|
||||
/**
|
||||
* Icon to display before the label
|
||||
*/
|
||||
icon?: ReactNode;
|
||||
|
||||
/**
|
||||
* Keyboard shortcut to display
|
||||
*/
|
||||
shortcut?: string;
|
||||
|
||||
/**
|
||||
* Callback when item is selected
|
||||
*/
|
||||
onSelect?: () => void;
|
||||
|
||||
/**
|
||||
* Item content
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* DropdownMenuSeparator component props
|
||||
*/
|
||||
export interface DropdownMenuSeparatorProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
/**
|
||||
* DropdownMenuLabel component props
|
||||
*/
|
||||
export interface DropdownMenuLabelProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* DropdownMenuGroup component props
|
||||
*/
|
||||
export interface DropdownMenuGroupProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Group label
|
||||
*/
|
||||
label?: string;
|
||||
|
||||
/**
|
||||
* Group items
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* DropdownMenu ref type
|
||||
*/
|
||||
export type DropdownMenuRef = HTMLDivElement;
|
||||
145
packages/ui/src/DropdownMenu/variants.ts
Normal file
145
packages/ui/src/DropdownMenu/variants.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { createVariants } from '../system/variants';
|
||||
|
||||
/**
|
||||
* Dropdown menu content variant definitions
|
||||
*/
|
||||
export const dropdownMenuContentVariants = createVariants({
|
||||
base: [
|
||||
// Layout
|
||||
'absolute',
|
||||
'z-[var(--z-dropdown)]',
|
||||
'min-w-[10rem]',
|
||||
'py-1',
|
||||
// Styling - Sharp corners, blueprint aesthetic
|
||||
'bg-surface border border-border',
|
||||
'rounded-none',
|
||||
// Shadow
|
||||
'shadow-lg',
|
||||
// Animation
|
||||
'transition-all duration-150',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
state: {
|
||||
entering: 'opacity-0 scale-95',
|
||||
entered: 'opacity-100 scale-100',
|
||||
exiting: 'opacity-0 scale-95',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
state: 'entered',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Dropdown menu item variant definitions
|
||||
*/
|
||||
export const dropdownMenuItemVariants = createVariants({
|
||||
base: [
|
||||
// Layout
|
||||
'relative w-full',
|
||||
'flex items-center gap-2',
|
||||
'px-3 py-2',
|
||||
// Typography
|
||||
'font-mono text-sm text-left',
|
||||
'text-foreground',
|
||||
// Interaction
|
||||
'cursor-pointer',
|
||||
'transition-colors duration-150',
|
||||
// Focus
|
||||
'outline-none',
|
||||
'hover:bg-accent/10',
|
||||
'focus-visible:bg-accent/10',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
disabled: {
|
||||
'true': 'opacity-50 cursor-not-allowed hover:bg-transparent',
|
||||
'false': '',
|
||||
},
|
||||
destructive: {
|
||||
'true': 'text-error hover:bg-error/10 focus-visible:bg-error/10',
|
||||
'false': '',
|
||||
},
|
||||
active: {
|
||||
'true': 'bg-accent/10',
|
||||
'false': '',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
disabled: 'false',
|
||||
destructive: 'false',
|
||||
active: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Dropdown menu item icon variant definitions
|
||||
*/
|
||||
export const dropdownMenuItemIconVariants = createVariants({
|
||||
base: [
|
||||
'flex-shrink-0',
|
||||
'w-4 h-4',
|
||||
'text-foreground-muted',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
destructive: {
|
||||
'true': 'text-error',
|
||||
'false': '',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
destructive: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Dropdown menu item shortcut variant definitions
|
||||
*/
|
||||
export const dropdownMenuItemShortcutVariants = createVariants({
|
||||
base: [
|
||||
'ml-auto',
|
||||
'font-mono text-xs',
|
||||
'text-foreground-muted',
|
||||
'opacity-60',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Dropdown menu separator variant definitions
|
||||
*/
|
||||
export const dropdownMenuSeparatorVariants = createVariants({
|
||||
base: [
|
||||
'my-1',
|
||||
'h-px',
|
||||
'bg-border',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Dropdown menu label variant definitions
|
||||
*/
|
||||
export const dropdownMenuLabelVariants = createVariants({
|
||||
base: [
|
||||
'px-3 py-2',
|
||||
'font-mono text-xs font-semibold',
|
||||
'text-foreground-muted',
|
||||
'uppercase tracking-wider',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
|
@ -156,6 +156,54 @@ export const icons = {
|
|||
viewBox: '0 0 24 24',
|
||||
path: 'M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z',
|
||||
},
|
||||
checkCircle: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z',
|
||||
},
|
||||
xCircle: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z',
|
||||
},
|
||||
chevronsLeft: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M18.41 16.59L13.82 12l4.59-4.59L17 6l-6 6 6 6 1.41-1.41zM6 6h2v12H6V6z',
|
||||
},
|
||||
chevronsRight: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M5.59 7.41L10.18 12l-4.59 4.59L7 18l6-6-6-6-1.41 1.41zM16 6h2v12h-2V6z',
|
||||
},
|
||||
moreHorizontal: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z',
|
||||
},
|
||||
bell: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z',
|
||||
},
|
||||
slash: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z',
|
||||
},
|
||||
arrowRight: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z',
|
||||
},
|
||||
circle: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z',
|
||||
},
|
||||
badgeCheck: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M23 12l-2.44-2.79.34-3.69-3.61-.82-1.89-3.2L12 2.96 8.6 1.5 6.71 4.69 3.1 5.5l.34 3.7L1 12l2.44 2.79-.34 3.7 3.61.82L8.6 22.5l3.4-1.47 3.4 1.46 1.89-3.19 3.61-.82-.34-3.69L23 12zm-12.91 4.72l-3.8-3.81 1.48-1.48 2.32 2.33 5.85-5.87 1.48 1.48-7.33 7.35z',
|
||||
},
|
||||
download: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z',
|
||||
},
|
||||
chevronLeft: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
|
|
|||
|
|
@ -8,16 +8,16 @@ export const inputVariants = createVariants({
|
|||
base: [
|
||||
// Layout
|
||||
'flex w-full',
|
||||
// Typography
|
||||
'font-sans',
|
||||
// Borders & Radius
|
||||
'rounded-md border',
|
||||
// Typography - Monospace for inputs
|
||||
'font-mono',
|
||||
// Borders & Radius - SHARP CORNERS
|
||||
'rounded-none border',
|
||||
// Background - Pure white to stand out
|
||||
'bg-surface',
|
||||
// Transitions
|
||||
'transition-base',
|
||||
// Focus
|
||||
'focus-ring',
|
||||
'transition-colors duration-150',
|
||||
// Focus - Copper accent
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
||||
// Placeholder
|
||||
'placeholder:text-foreground-tertiary',
|
||||
// File input
|
||||
|
|
|
|||
174
packages/ui/src/InstallSnippet/InstallSnippet.tsx
Normal file
174
packages/ui/src/InstallSnippet/InstallSnippet.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@tpmjs/utils/cn';
|
||||
import { forwardRef, useCallback, useState } from 'react';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import type { InstallSnippetProps, PackageManager } from './types';
|
||||
import {
|
||||
installSnippetCodeVariants,
|
||||
installSnippetCopyButtonVariants,
|
||||
installSnippetTabVariants,
|
||||
installSnippetTabsVariants,
|
||||
installSnippetVariants,
|
||||
} from './variants';
|
||||
|
||||
/**
|
||||
* Package manager configurations
|
||||
*/
|
||||
const PACKAGE_MANAGERS: Record<
|
||||
PackageManager,
|
||||
{
|
||||
label: string;
|
||||
install: string;
|
||||
devInstall: string;
|
||||
globalInstall: string;
|
||||
}
|
||||
> = {
|
||||
npm: {
|
||||
label: 'npm',
|
||||
install: 'npm install',
|
||||
devInstall: 'npm install -D',
|
||||
globalInstall: 'npm install -g',
|
||||
},
|
||||
pnpm: {
|
||||
label: 'pnpm',
|
||||
install: 'pnpm add',
|
||||
devInstall: 'pnpm add -D',
|
||||
globalInstall: 'pnpm add -g',
|
||||
},
|
||||
yarn: {
|
||||
label: 'yarn',
|
||||
install: 'yarn add',
|
||||
devInstall: 'yarn add -D',
|
||||
globalInstall: 'yarn global add',
|
||||
},
|
||||
bun: {
|
||||
label: 'bun',
|
||||
install: 'bun add',
|
||||
devInstall: 'bun add -d',
|
||||
globalInstall: 'bun add -g',
|
||||
},
|
||||
};
|
||||
|
||||
const MANAGER_ORDER: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'];
|
||||
|
||||
/**
|
||||
* InstallSnippet component
|
||||
*
|
||||
* A component for displaying package installation commands with package manager tabs.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { InstallSnippet } from '@tpmjs/ui/InstallSnippet/InstallSnippet';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return (
|
||||
* <InstallSnippet
|
||||
* packageName="@tpmjs/core"
|
||||
* version="^1.0.0"
|
||||
* defaultManager="pnpm"
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const InstallSnippet = forwardRef<HTMLDivElement, InstallSnippetProps>(
|
||||
(
|
||||
{
|
||||
packageName,
|
||||
version,
|
||||
defaultManager = 'npm',
|
||||
showTabs = true,
|
||||
installType = 'dependencies',
|
||||
copyable = true,
|
||||
variant = 'default',
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [activeManager, setActiveManager] = useState<PackageManager>(defaultManager);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Generate install command
|
||||
const getInstallCommand = useCallback(
|
||||
(manager: PackageManager): string => {
|
||||
const config = PACKAGE_MANAGERS[manager];
|
||||
let command: string;
|
||||
|
||||
switch (installType) {
|
||||
case 'devDependencies':
|
||||
command = config.devInstall;
|
||||
break;
|
||||
case 'global':
|
||||
command = config.globalInstall;
|
||||
break;
|
||||
default:
|
||||
command = config.install;
|
||||
}
|
||||
|
||||
const pkg = version ? `${packageName}@${version}` : packageName;
|
||||
return `${command} ${pkg}`;
|
||||
},
|
||||
[packageName, version, installType]
|
||||
);
|
||||
|
||||
const currentCommand = getInstallCommand(activeManager);
|
||||
|
||||
// Copy to clipboard
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(currentCommand);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}, [currentCommand]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(installSnippetVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Tabs */}
|
||||
{showTabs && (
|
||||
<div className={installSnippetTabsVariants({ variant })}>
|
||||
{MANAGER_ORDER.map((manager) => (
|
||||
<button
|
||||
key={manager}
|
||||
type="button"
|
||||
onClick={() => setActiveManager(manager)}
|
||||
className={installSnippetTabVariants({
|
||||
variant,
|
||||
active: activeManager === manager ? 'true' : 'false',
|
||||
})}
|
||||
>
|
||||
{PACKAGE_MANAGERS[manager].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code */}
|
||||
<div className={installSnippetCodeVariants({ variant })}>
|
||||
<code className="flex-1 whitespace-nowrap">{currentCommand}</code>
|
||||
|
||||
{copyable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={installSnippetCopyButtonVariants({ variant, copied: copied ? 'true' : 'false' })}
|
||||
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||
>
|
||||
<Icon icon={copied ? 'check' : 'copy'} size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
InstallSnippet.displayName = 'InstallSnippet';
|
||||
56
packages/ui/src/InstallSnippet/types.ts
Normal file
56
packages/ui/src/InstallSnippet/types.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
/**
|
||||
* Package manager types
|
||||
*/
|
||||
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
|
||||
|
||||
/**
|
||||
* InstallSnippet component props
|
||||
*/
|
||||
export interface InstallSnippetProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Package name to install
|
||||
*/
|
||||
packageName: string;
|
||||
|
||||
/**
|
||||
* Package version (optional)
|
||||
*/
|
||||
version?: string;
|
||||
|
||||
/**
|
||||
* Default package manager
|
||||
* @default 'npm'
|
||||
*/
|
||||
defaultManager?: PackageManager;
|
||||
|
||||
/**
|
||||
* Whether to show package manager tabs
|
||||
* @default true
|
||||
*/
|
||||
showTabs?: boolean;
|
||||
|
||||
/**
|
||||
* Install type
|
||||
* @default 'dependencies'
|
||||
*/
|
||||
installType?: 'dependencies' | 'devDependencies' | 'global';
|
||||
|
||||
/**
|
||||
* Whether the snippet is copyable
|
||||
* @default true
|
||||
*/
|
||||
copyable?: boolean;
|
||||
|
||||
/**
|
||||
* Variant style
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: 'default' | 'minimal' | 'dark';
|
||||
}
|
||||
|
||||
/**
|
||||
* InstallSnippet ref type
|
||||
*/
|
||||
export type InstallSnippetRef = HTMLDivElement;
|
||||
148
packages/ui/src/InstallSnippet/variants.ts
Normal file
148
packages/ui/src/InstallSnippet/variants.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { createVariants } from '../system/variants';
|
||||
|
||||
/**
|
||||
* InstallSnippet container variant definitions
|
||||
*/
|
||||
export const installSnippetVariants = createVariants({
|
||||
base: [
|
||||
'w-full',
|
||||
'border border-border',
|
||||
'rounded-none',
|
||||
'overflow-hidden',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-surface',
|
||||
minimal: 'border-0 bg-transparent',
|
||||
dark: 'bg-foreground border-foreground',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* InstallSnippet tabs variant definitions
|
||||
*/
|
||||
export const installSnippetTabsVariants = createVariants({
|
||||
base: [
|
||||
'flex items-center',
|
||||
'border-b border-border',
|
||||
'px-1',
|
||||
'gap-0',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-surface',
|
||||
minimal: 'bg-transparent border-0',
|
||||
dark: 'bg-foreground border-foreground/20',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* InstallSnippet tab variant definitions
|
||||
*/
|
||||
export const installSnippetTabVariants = createVariants({
|
||||
base: [
|
||||
'px-3 py-2',
|
||||
'font-mono text-xs',
|
||||
'border-b-2 border-transparent',
|
||||
'-mb-px',
|
||||
'cursor-pointer',
|
||||
'transition-colors duration-150',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'text-foreground-muted hover:text-foreground',
|
||||
minimal: 'text-foreground-muted hover:text-foreground',
|
||||
dark: 'text-background/60 hover:text-background',
|
||||
},
|
||||
active: {
|
||||
'true': '',
|
||||
'false': '',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
active: 'false',
|
||||
},
|
||||
|
||||
compoundVariants: [
|
||||
{ conditions: { variant: 'default', active: 'true' }, className: 'text-foreground border-primary' },
|
||||
{ conditions: { variant: 'minimal', active: 'true' }, className: 'text-foreground border-primary' },
|
||||
{ conditions: { variant: 'dark', active: 'true' }, className: 'text-background border-background' },
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* InstallSnippet code area variant definitions
|
||||
*/
|
||||
export const installSnippetCodeVariants = createVariants({
|
||||
base: [
|
||||
'flex items-center justify-between',
|
||||
'px-4 py-3',
|
||||
'font-mono text-sm',
|
||||
'overflow-x-auto',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'text-foreground',
|
||||
minimal: 'text-foreground',
|
||||
dark: 'text-background',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* InstallSnippet copy button variant definitions
|
||||
*/
|
||||
export const installSnippetCopyButtonVariants = createVariants({
|
||||
base: [
|
||||
'flex-shrink-0',
|
||||
'p-1.5',
|
||||
'-m-1',
|
||||
'ml-3',
|
||||
'rounded-none',
|
||||
'transition-colors duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'text-foreground-muted hover:text-foreground hover:bg-accent/10',
|
||||
minimal: 'text-foreground-muted hover:text-foreground hover:bg-accent/10',
|
||||
dark: 'text-background/60 hover:text-background hover:bg-background/10',
|
||||
},
|
||||
copied: {
|
||||
'true': '',
|
||||
'false': '',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
copied: 'false',
|
||||
},
|
||||
|
||||
compoundVariants: [
|
||||
{ conditions: { variant: 'default', copied: 'true' }, className: 'text-success' },
|
||||
{ conditions: { variant: 'minimal', copied: 'true' }, className: 'text-success' },
|
||||
{ conditions: { variant: 'dark', copied: 'true' }, className: 'text-success' },
|
||||
],
|
||||
});
|
||||
301
packages/ui/src/Modal/Modal.tsx
Normal file
301
packages/ui/src/Modal/Modal.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@tpmjs/utils/cn';
|
||||
import { forwardRef, useCallback, useEffect, useId, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import type {
|
||||
ModalBodyProps,
|
||||
ModalFooterProps,
|
||||
ModalHeaderProps,
|
||||
ModalProps,
|
||||
} from './types';
|
||||
import {
|
||||
modalBackdropVariants,
|
||||
modalBodyVariants,
|
||||
modalCloseButtonVariants,
|
||||
modalContainerVariants,
|
||||
modalFooterVariants,
|
||||
modalHeaderVariants,
|
||||
modalPanelVariants,
|
||||
modalTitleVariants,
|
||||
} from './variants';
|
||||
|
||||
/**
|
||||
* Modal component
|
||||
*
|
||||
* A dialog overlay with focus trap, keyboard support, and accessibility features.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Modal } from '@tpmjs/ui/Modal/Modal';
|
||||
* import { Button } from '@tpmjs/ui/Button/Button';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const [open, setOpen] = useState(false);
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <Button onClick={() => setOpen(true)}>Open Modal</Button>
|
||||
* <Modal
|
||||
* open={open}
|
||||
* onClose={() => setOpen(false)}
|
||||
* title="Confirm Action"
|
||||
* footer={
|
||||
* <>
|
||||
* <Button variant="outline" onClick={() => setOpen(false)}>
|
||||
* Cancel
|
||||
* </Button>
|
||||
* <Button onClick={() => setOpen(false)}>
|
||||
* Confirm
|
||||
* </Button>
|
||||
* </>
|
||||
* }
|
||||
* >
|
||||
* <p>Are you sure you want to proceed?</p>
|
||||
* </Modal>
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Modal = forwardRef<HTMLDivElement, ModalProps>(
|
||||
(
|
||||
{
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
size = 'md',
|
||||
closeOnBackdropClick = true,
|
||||
closeOnEscape = true,
|
||||
showCloseButton = true,
|
||||
footer,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const previousActiveElement = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!open || !closeOnEscape) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, closeOnEscape, onClose]);
|
||||
|
||||
// Handle focus trap
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Store the previously focused element
|
||||
previousActiveElement.current = document.activeElement as HTMLElement;
|
||||
|
||||
// Focus the panel
|
||||
const timer = setTimeout(() => {
|
||||
panelRef.current?.focus();
|
||||
}, 0);
|
||||
|
||||
// Prevent body scroll
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.body.style.overflow = originalOverflow;
|
||||
|
||||
// Restore focus to the previously focused element
|
||||
previousActiveElement.current?.focus();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Handle backdrop click
|
||||
const handleBackdropClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
if (closeOnBackdropClick && event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[closeOnBackdropClick, onClose]
|
||||
);
|
||||
|
||||
// Handle focus trap within modal
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
|
||||
const focusableElements = panel.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (event.shiftKey && document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement?.focus();
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Only render in browser (for SSR compatibility)
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const modalContent = (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={modalBackdropVariants({ state: 'entered' })}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Container */}
|
||||
<div
|
||||
className={modalContainerVariants({})}
|
||||
onClick={handleBackdropClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Panel */}
|
||||
<div
|
||||
ref={(node) => {
|
||||
// Handle both refs
|
||||
(panelRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
if (typeof ref === 'function') {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
ref.current = node;
|
||||
}
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
aria-describedby={description ? descriptionId : undefined}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
modalPanelVariants({ size, state: 'entered' }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className={modalHeaderVariants({})}>
|
||||
{title && (
|
||||
<h2 id={titleId} className={modalTitleVariants({})}>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={modalCloseButtonVariants({})}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<Icon icon="x" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hidden description for screen readers */}
|
||||
{description && (
|
||||
<p id={descriptionId} className="sr-only">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className={modalBodyVariants({})}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className={modalFooterVariants({})}>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
);
|
||||
|
||||
Modal.displayName = 'Modal';
|
||||
|
||||
/**
|
||||
* ModalHeader component for custom headers
|
||||
*/
|
||||
export const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(modalHeaderVariants({}), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
ModalHeader.displayName = 'ModalHeader';
|
||||
|
||||
/**
|
||||
* ModalBody component for custom body content
|
||||
*/
|
||||
export const ModalBody = forwardRef<HTMLDivElement, ModalBodyProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(modalBodyVariants({}), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
ModalBody.displayName = 'ModalBody';
|
||||
|
||||
/**
|
||||
* ModalFooter component for custom footers
|
||||
*/
|
||||
export const ModalFooter = forwardRef<HTMLDivElement, ModalFooterProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(modalFooterVariants({}), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
ModalFooter.displayName = 'ModalFooter';
|
||||
86
packages/ui/src/Modal/types.ts
Normal file
86
packages/ui/src/Modal/types.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Modal component props
|
||||
*/
|
||||
export interface ModalProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
/**
|
||||
* Whether the modal is open
|
||||
*/
|
||||
open: boolean;
|
||||
|
||||
/**
|
||||
* Callback when the modal should close
|
||||
*/
|
||||
onClose: () => void;
|
||||
|
||||
/**
|
||||
* Modal title (displayed in header)
|
||||
*/
|
||||
title?: ReactNode;
|
||||
|
||||
/**
|
||||
* Modal description (for accessibility)
|
||||
*/
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* Size of the modal
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
|
||||
/**
|
||||
* Whether to close on backdrop click
|
||||
* @default true
|
||||
*/
|
||||
closeOnBackdropClick?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to close on Escape key
|
||||
* @default true
|
||||
*/
|
||||
closeOnEscape?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to show the close button
|
||||
* @default true
|
||||
*/
|
||||
showCloseButton?: boolean;
|
||||
|
||||
/**
|
||||
* Footer content (buttons, actions)
|
||||
*/
|
||||
footer?: ReactNode;
|
||||
|
||||
/**
|
||||
* Modal content
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ModalHeader component props
|
||||
*/
|
||||
export interface ModalHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ModalBody component props
|
||||
*/
|
||||
export interface ModalBodyProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ModalFooter component props
|
||||
*/
|
||||
export interface ModalFooterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal ref type
|
||||
*/
|
||||
export type ModalRef = HTMLDivElement;
|
||||
162
packages/ui/src/Modal/variants.ts
Normal file
162
packages/ui/src/Modal/variants.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { createVariants } from '../system/variants';
|
||||
|
||||
/**
|
||||
* Modal backdrop variant definitions
|
||||
*/
|
||||
export const modalBackdropVariants = createVariants({
|
||||
base: [
|
||||
'fixed inset-0',
|
||||
'bg-foreground/80',
|
||||
'backdrop-blur-sm',
|
||||
'z-[var(--z-modal-backdrop)]',
|
||||
'transition-opacity duration-200',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
state: {
|
||||
entering: 'opacity-0',
|
||||
entered: 'opacity-100',
|
||||
exiting: 'opacity-0',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
state: 'entered',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Modal container variant definitions
|
||||
*/
|
||||
export const modalContainerVariants = createVariants({
|
||||
base: [
|
||||
'fixed inset-0',
|
||||
'z-[var(--z-modal)]',
|
||||
'flex items-center justify-center',
|
||||
'p-4',
|
||||
'overflow-y-auto',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Modal panel variant definitions
|
||||
*/
|
||||
export const modalPanelVariants = createVariants({
|
||||
base: [
|
||||
// Layout
|
||||
'relative w-full',
|
||||
'flex flex-col',
|
||||
'max-h-[calc(100vh-2rem)]',
|
||||
// Styling - Sharp corners, blueprint aesthetic
|
||||
'bg-surface border border-border',
|
||||
'rounded-none',
|
||||
// Shadow
|
||||
'shadow-lg',
|
||||
// Animation
|
||||
'transition-all duration-200',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
full: 'max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)]',
|
||||
},
|
||||
state: {
|
||||
entering: 'opacity-0 scale-95 translate-y-4',
|
||||
entered: 'opacity-100 scale-100 translate-y-0',
|
||||
exiting: 'opacity-0 scale-95 translate-y-4',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
state: 'entered',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Modal header variant definitions
|
||||
*/
|
||||
export const modalHeaderVariants = createVariants({
|
||||
base: [
|
||||
'flex items-center justify-between',
|
||||
'px-6 py-4',
|
||||
'border-b border-border',
|
||||
'bg-surface',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Modal body variant definitions
|
||||
*/
|
||||
export const modalBodyVariants = createVariants({
|
||||
base: [
|
||||
'flex-1',
|
||||
'px-6 py-4',
|
||||
'overflow-y-auto',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Modal footer variant definitions
|
||||
*/
|
||||
export const modalFooterVariants = createVariants({
|
||||
base: [
|
||||
'flex items-center justify-end gap-3',
|
||||
'px-6 py-4',
|
||||
'border-t border-border',
|
||||
'bg-surface',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Modal title variant definitions
|
||||
*/
|
||||
export const modalTitleVariants = createVariants({
|
||||
base: [
|
||||
'font-mono font-semibold text-lg',
|
||||
'text-foreground',
|
||||
'lowercase',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Modal close button variant definitions
|
||||
*/
|
||||
export const modalCloseButtonVariants = createVariants({
|
||||
base: [
|
||||
'p-2 -m-2',
|
||||
'text-foreground-muted',
|
||||
'hover:text-foreground hover:bg-accent/10',
|
||||
'transition-colors duration-150',
|
||||
'rounded-none',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
339
packages/ui/src/Pagination/Pagination.tsx
Normal file
339
packages/ui/src/Pagination/Pagination.tsx
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@tpmjs/utils/cn';
|
||||
import { forwardRef, useCallback, useMemo } from 'react';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import type {
|
||||
PaginationEllipsisProps,
|
||||
PaginationItemProps,
|
||||
PaginationProps,
|
||||
} from './types';
|
||||
import {
|
||||
paginationEllipsisVariants,
|
||||
paginationInfoVariants,
|
||||
paginationItemVariants,
|
||||
paginationNavButtonVariants,
|
||||
paginationVariants,
|
||||
} from './variants';
|
||||
|
||||
/**
|
||||
* Generate pagination range with ellipsis
|
||||
*/
|
||||
function generatePaginationRange(
|
||||
page: number,
|
||||
totalPages: number,
|
||||
siblings: number,
|
||||
boundaries: number
|
||||
): (number | 'ellipsis')[] {
|
||||
const range: (number | 'ellipsis')[] = [];
|
||||
|
||||
// Always show first `boundaries` pages
|
||||
for (let i = 1; i <= Math.min(boundaries, totalPages); i++) {
|
||||
range.push(i);
|
||||
}
|
||||
|
||||
// Calculate sibling range
|
||||
const siblingStart = Math.max(
|
||||
boundaries + 1,
|
||||
page - siblings
|
||||
);
|
||||
const siblingEnd = Math.min(
|
||||
totalPages - boundaries,
|
||||
page + siblings
|
||||
);
|
||||
|
||||
// Add ellipsis if there's a gap after boundaries
|
||||
if (siblingStart > boundaries + 1) {
|
||||
range.push('ellipsis');
|
||||
}
|
||||
|
||||
// Add sibling pages
|
||||
for (let i = siblingStart; i <= siblingEnd; i++) {
|
||||
if (!range.includes(i)) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Add ellipsis if there's a gap before end boundaries
|
||||
if (siblingEnd < totalPages - boundaries) {
|
||||
range.push('ellipsis');
|
||||
}
|
||||
|
||||
// Always show last `boundaries` pages
|
||||
for (let i = Math.max(totalPages - boundaries + 1, 1); i <= totalPages; i++) {
|
||||
if (!range.includes(i)) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination component
|
||||
*
|
||||
* A component for navigating between pages of content.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Pagination } from '@tpmjs/ui/Pagination/Pagination';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const [page, setPage] = useState(1);
|
||||
*
|
||||
* return (
|
||||
* <Pagination
|
||||
* page={page}
|
||||
* totalPages={10}
|
||||
* onPageChange={setPage}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Pagination = forwardRef<HTMLElement, PaginationProps>(
|
||||
(
|
||||
{
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
siblings = 1,
|
||||
boundaries = 1,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
showFirstLast = false,
|
||||
showPrevNext = true,
|
||||
previousLabel = 'Previous',
|
||||
nextLabel = 'Next',
|
||||
disabled = false,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const paginationRange = useMemo(
|
||||
() => generatePaginationRange(page, totalPages, siblings, boundaries),
|
||||
[page, totalPages, siblings, boundaries]
|
||||
);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(newPage: number) => {
|
||||
if (newPage >= 1 && newPage <= totalPages && newPage !== page) {
|
||||
onPageChange(newPage);
|
||||
}
|
||||
},
|
||||
[page, totalPages, onPageChange]
|
||||
);
|
||||
|
||||
const isFirstPage = page === 1;
|
||||
const isLastPage = page === totalPages;
|
||||
|
||||
// Simple variant: just prev/next with page info
|
||||
if (variant === 'simple') {
|
||||
return (
|
||||
<nav
|
||||
ref={ref}
|
||||
role="navigation"
|
||||
aria-label="Pagination"
|
||||
className={cn(paginationVariants({ size }), className)}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isFirstPage}
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
className={paginationNavButtonVariants({ size, disabled: (disabled || isFirstPage) ? 'true' : 'false' })}
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<Icon icon="chevronLeft" size={size === 'sm' ? 'xs' : 'sm'} />
|
||||
{previousLabel}
|
||||
</button>
|
||||
|
||||
<span className={paginationInfoVariants({ size })}>
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isLastPage}
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
className={paginationNavButtonVariants({ size, disabled: (disabled || isLastPage) ? 'true' : 'false' })}
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
{nextLabel}
|
||||
<Icon icon="chevronRight" size={size === 'sm' ? 'xs' : 'sm'} />
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// Minimal variant: just prev/next icons
|
||||
if (variant === 'minimal') {
|
||||
return (
|
||||
<nav
|
||||
ref={ref}
|
||||
role="navigation"
|
||||
aria-label="Pagination"
|
||||
className={cn(paginationVariants({ size }), className)}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isFirstPage}
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
className={paginationItemVariants({ size, disabled: (disabled || isFirstPage) ? 'true' : 'false' })}
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<Icon icon="chevronLeft" size={size === 'sm' ? 'xs' : 'sm'} />
|
||||
</button>
|
||||
|
||||
<span className={paginationInfoVariants({ size })}>
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isLastPage}
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
className={paginationItemVariants({ size, disabled: (disabled || isLastPage) ? 'true' : 'false' })}
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
<Icon icon="chevronRight" size={size === 'sm' ? 'xs' : 'sm'} />
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// Default variant: full pagination with page numbers
|
||||
return (
|
||||
<nav
|
||||
ref={ref}
|
||||
role="navigation"
|
||||
aria-label="Pagination"
|
||||
className={cn(paginationVariants({ size }), className)}
|
||||
{...props}
|
||||
>
|
||||
{/* First page button */}
|
||||
{showFirstLast && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isFirstPage}
|
||||
onClick={() => handlePageChange(1)}
|
||||
className={paginationItemVariants({ size, disabled: (disabled || isFirstPage) ? 'true' : 'false' })}
|
||||
aria-label="Go to first page"
|
||||
>
|
||||
<Icon icon="chevronsLeft" size={size === 'sm' ? 'xs' : 'sm'} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Previous button */}
|
||||
{showPrevNext && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isFirstPage}
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
className={paginationItemVariants({ size, disabled: (disabled || isFirstPage) ? 'true' : 'false' })}
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<Icon icon="chevronLeft" size={size === 'sm' ? 'xs' : 'sm'} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Page numbers */}
|
||||
{paginationRange.map((item, index) => {
|
||||
if (item === 'ellipsis') {
|
||||
return (
|
||||
<PaginationEllipsis
|
||||
key={`ellipsis-${index}`}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => handlePageChange(item)}
|
||||
className={paginationItemVariants({
|
||||
size,
|
||||
active: item === page ? 'true' : 'false',
|
||||
disabled: disabled ? 'true' : 'false',
|
||||
})}
|
||||
aria-label={`Go to page ${item}`}
|
||||
aria-current={item === page ? 'page' : undefined}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Next button */}
|
||||
{showPrevNext && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isLastPage}
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
className={paginationItemVariants({ size, disabled: (disabled || isLastPage) ? 'true' : 'false' })}
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
<Icon icon="chevronRight" size={size === 'sm' ? 'xs' : 'sm'} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Last page button */}
|
||||
{showFirstLast && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isLastPage}
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
className={paginationItemVariants({ size, disabled: (disabled || isLastPage) ? 'true' : 'false' })}
|
||||
aria-label="Go to last page"
|
||||
>
|
||||
<Icon icon="chevronsRight" size={size === 'sm' ? 'xs' : 'sm'} />
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Pagination.displayName = 'Pagination';
|
||||
|
||||
/**
|
||||
* PaginationItem component (for custom usage)
|
||||
*/
|
||||
export const PaginationItem = forwardRef<HTMLButtonElement, PaginationItemProps>(
|
||||
({ active = false, disabled = false, size = 'md', children, className, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(paginationItemVariants({ size, active: active ? 'true' : 'false', disabled: disabled ? 'true' : 'false' }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
|
||||
PaginationItem.displayName = 'PaginationItem';
|
||||
|
||||
/**
|
||||
* PaginationEllipsis component
|
||||
*/
|
||||
export const PaginationEllipsis = forwardRef<HTMLSpanElement, PaginationEllipsisProps>(
|
||||
({ size = 'md', className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
aria-hidden="true"
|
||||
className={cn(paginationEllipsisVariants({ size }), className)}
|
||||
{...props}
|
||||
>
|
||||
<Icon icon="moreHorizontal" size={size === 'sm' ? 'xs' : 'sm'} />
|
||||
</span>
|
||||
)
|
||||
);
|
||||
|
||||
PaginationEllipsis.displayName = 'PaginationEllipsis';
|
||||
125
packages/ui/src/Pagination/types.ts
Normal file
125
packages/ui/src/Pagination/types.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Pagination size types
|
||||
*/
|
||||
export type PaginationSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Pagination variant types
|
||||
*/
|
||||
export type PaginationVariant = 'default' | 'simple' | 'minimal';
|
||||
|
||||
/**
|
||||
* Pagination component props
|
||||
*/
|
||||
export interface PaginationProps extends HTMLAttributes<HTMLElement> {
|
||||
/**
|
||||
* Current page (1-indexed)
|
||||
*/
|
||||
page: number;
|
||||
|
||||
/**
|
||||
* Total number of pages
|
||||
*/
|
||||
totalPages: number;
|
||||
|
||||
/**
|
||||
* Callback when page changes
|
||||
*/
|
||||
onPageChange: (page: number) => void;
|
||||
|
||||
/**
|
||||
* Number of sibling pages to show on each side
|
||||
* @default 1
|
||||
*/
|
||||
siblings?: number;
|
||||
|
||||
/**
|
||||
* Number of boundary pages to show at start/end
|
||||
* @default 1
|
||||
*/
|
||||
boundaries?: number;
|
||||
|
||||
/**
|
||||
* Size variant
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: PaginationSize;
|
||||
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: PaginationVariant;
|
||||
|
||||
/**
|
||||
* Whether to show first/last page buttons
|
||||
* @default false
|
||||
*/
|
||||
showFirstLast?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to show previous/next buttons
|
||||
* @default true
|
||||
*/
|
||||
showPrevNext?: boolean;
|
||||
|
||||
/**
|
||||
* Label for previous button
|
||||
* @default 'Previous'
|
||||
*/
|
||||
previousLabel?: ReactNode;
|
||||
|
||||
/**
|
||||
* Label for next button
|
||||
* @default 'Next'
|
||||
*/
|
||||
nextLabel?: ReactNode;
|
||||
|
||||
/**
|
||||
* Whether the pagination is disabled
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* PaginationItem component props
|
||||
*/
|
||||
export interface PaginationItemProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
/**
|
||||
* Whether this is the current page
|
||||
*/
|
||||
active?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the item is disabled
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Size variant
|
||||
*/
|
||||
size?: PaginationSize;
|
||||
|
||||
/**
|
||||
* Item content
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* PaginationEllipsis component props
|
||||
*/
|
||||
export interface PaginationEllipsisProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
/**
|
||||
* Size variant
|
||||
*/
|
||||
size?: PaginationSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination ref type
|
||||
*/
|
||||
export type PaginationRef = HTMLElement;
|
||||
151
packages/ui/src/Pagination/variants.ts
Normal file
151
packages/ui/src/Pagination/variants.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { createVariants } from '../system/variants';
|
||||
|
||||
/**
|
||||
* Pagination container variant definitions
|
||||
*/
|
||||
export const paginationVariants = createVariants({
|
||||
base: [
|
||||
'flex items-center gap-1',
|
||||
'font-mono',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Pagination item variant definitions
|
||||
*/
|
||||
export const paginationItemVariants = createVariants({
|
||||
base: [
|
||||
// Layout
|
||||
'inline-flex items-center justify-center',
|
||||
'font-mono',
|
||||
// Styling - Sharp corners
|
||||
'border border-transparent',
|
||||
'rounded-none',
|
||||
// Interaction
|
||||
'cursor-pointer',
|
||||
'transition-all duration-150',
|
||||
// Focus
|
||||
'outline-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'h-7 min-w-7 px-2 text-xs',
|
||||
md: 'h-9 min-w-9 px-3 text-sm',
|
||||
lg: 'h-11 min-w-11 px-4 text-base',
|
||||
},
|
||||
active: {
|
||||
'true': 'bg-primary text-primary-foreground border-primary',
|
||||
'false': 'text-foreground-muted hover:text-foreground hover:bg-accent/10 hover:border-border',
|
||||
},
|
||||
disabled: {
|
||||
'true': 'opacity-50 cursor-not-allowed pointer-events-none',
|
||||
'false': '',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
active: 'false',
|
||||
disabled: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Pagination nav button variant definitions
|
||||
*/
|
||||
export const paginationNavButtonVariants = createVariants({
|
||||
base: [
|
||||
// Layout
|
||||
'inline-flex items-center justify-center gap-1',
|
||||
'font-mono',
|
||||
// Styling
|
||||
'border border-border',
|
||||
'rounded-none',
|
||||
// Interaction
|
||||
'cursor-pointer',
|
||||
'transition-all duration-150',
|
||||
// Focus
|
||||
'outline-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
||||
// States
|
||||
'text-foreground-muted',
|
||||
'hover:text-foreground hover:bg-accent/10',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'h-7 px-2 text-xs',
|
||||
md: 'h-9 px-3 text-sm',
|
||||
lg: 'h-11 px-4 text-base',
|
||||
},
|
||||
disabled: {
|
||||
'true': 'opacity-50 cursor-not-allowed pointer-events-none',
|
||||
'false': '',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
disabled: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Pagination ellipsis variant definitions
|
||||
*/
|
||||
export const paginationEllipsisVariants = createVariants({
|
||||
base: [
|
||||
'inline-flex items-center justify-center',
|
||||
'text-foreground-muted',
|
||||
'select-none',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'h-7 w-7 text-xs',
|
||||
md: 'h-9 w-9 text-sm',
|
||||
lg: 'h-11 w-11 text-base',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Pagination info variant definitions (for showing "Page X of Y")
|
||||
*/
|
||||
export const paginationInfoVariants = createVariants({
|
||||
base: [
|
||||
'text-foreground-muted',
|
||||
'font-mono',
|
||||
'mx-2',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
334
packages/ui/src/Popover/Popover.tsx
Normal file
334
packages/ui/src/Popover/Popover.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@tpmjs/utils/cn';
|
||||
import {
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { PopoverPlacement, PopoverProps } from './types';
|
||||
import {
|
||||
popoverArrowVariants,
|
||||
popoverBodyVariants,
|
||||
popoverContentVariants,
|
||||
} from './variants';
|
||||
|
||||
/**
|
||||
* Calculate position based on trigger and placement
|
||||
*/
|
||||
function calculatePosition(
|
||||
triggerRect: DOMRect,
|
||||
contentRect: DOMRect,
|
||||
placement: PopoverPlacement,
|
||||
offset: number
|
||||
): { top: number; left: number } {
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
top = triggerRect.top + scrollY - contentRect.height - offset;
|
||||
left = triggerRect.left + scrollX + (triggerRect.width - contentRect.width) / 2;
|
||||
break;
|
||||
case 'top-start':
|
||||
top = triggerRect.top + scrollY - contentRect.height - offset;
|
||||
left = triggerRect.left + scrollX;
|
||||
break;
|
||||
case 'top-end':
|
||||
top = triggerRect.top + scrollY - contentRect.height - offset;
|
||||
left = triggerRect.right + scrollX - contentRect.width;
|
||||
break;
|
||||
case 'bottom':
|
||||
top = triggerRect.bottom + scrollY + offset;
|
||||
left = triggerRect.left + scrollX + (triggerRect.width - contentRect.width) / 2;
|
||||
break;
|
||||
case 'bottom-start':
|
||||
top = triggerRect.bottom + scrollY + offset;
|
||||
left = triggerRect.left + scrollX;
|
||||
break;
|
||||
case 'bottom-end':
|
||||
top = triggerRect.bottom + scrollY + offset;
|
||||
left = triggerRect.right + scrollX - contentRect.width;
|
||||
break;
|
||||
case 'left':
|
||||
top = triggerRect.top + scrollY + (triggerRect.height - contentRect.height) / 2;
|
||||
left = triggerRect.left + scrollX - contentRect.width - offset;
|
||||
break;
|
||||
case 'left-start':
|
||||
top = triggerRect.top + scrollY;
|
||||
left = triggerRect.left + scrollX - contentRect.width - offset;
|
||||
break;
|
||||
case 'left-end':
|
||||
top = triggerRect.bottom + scrollY - contentRect.height;
|
||||
left = triggerRect.left + scrollX - contentRect.width - offset;
|
||||
break;
|
||||
case 'right':
|
||||
top = triggerRect.top + scrollY + (triggerRect.height - contentRect.height) / 2;
|
||||
left = triggerRect.right + scrollX + offset;
|
||||
break;
|
||||
case 'right-start':
|
||||
top = triggerRect.top + scrollY;
|
||||
left = triggerRect.right + scrollX + offset;
|
||||
break;
|
||||
case 'right-end':
|
||||
top = triggerRect.bottom + scrollY - contentRect.height;
|
||||
left = triggerRect.right + scrollX + offset;
|
||||
break;
|
||||
}
|
||||
|
||||
return { top, left };
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover component
|
||||
*
|
||||
* A floating content panel that appears next to a trigger element.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Popover } from '@tpmjs/ui/Popover/Popover';
|
||||
* import { Button } from '@tpmjs/ui/Button/Button';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return (
|
||||
* <Popover
|
||||
* content={<p>This is popover content</p>}
|
||||
* placement="bottom"
|
||||
* >
|
||||
* <Button>Click me</Button>
|
||||
* </Popover>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
|
||||
(
|
||||
{
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
content,
|
||||
placement = 'bottom',
|
||||
trigger = 'click',
|
||||
offset = 8,
|
||||
closeOnClickOutside = true,
|
||||
closeOnEscape = true,
|
||||
showDelay = 0,
|
||||
hideDelay = 0,
|
||||
hasArrow = false,
|
||||
disabled = false,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const isOpen = isControlled ? controlledOpen : internalOpen;
|
||||
|
||||
const triggerRef = useRef<HTMLElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const showTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
const setOpen = useCallback(
|
||||
(value: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(value);
|
||||
}
|
||||
onOpenChange?.(value);
|
||||
},
|
||||
[isControlled, onOpenChange]
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
if (disabled) return;
|
||||
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (showDelay > 0) {
|
||||
showTimeoutRef.current = setTimeout(() => {
|
||||
setOpen(true);
|
||||
}, showDelay);
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [disabled, showDelay, setOpen]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (showTimeoutRef.current) {
|
||||
clearTimeout(showTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (hideDelay > 0) {
|
||||
hideTimeoutRef.current = setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, hideDelay);
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [hideDelay, setOpen]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (isOpen) {
|
||||
handleClose();
|
||||
} else {
|
||||
handleOpen();
|
||||
}
|
||||
}, [isOpen, handleOpen, handleClose]);
|
||||
|
||||
// Update position when open
|
||||
useEffect(() => {
|
||||
if (!isOpen || !triggerRef.current || !contentRef.current) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
const triggerRect = triggerRef.current!.getBoundingClientRect();
|
||||
const contentRect = contentRef.current!.getBoundingClientRect();
|
||||
const newPosition = calculatePosition(triggerRect, contentRect, placement, offset);
|
||||
setPosition(newPosition);
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
// Update on scroll/resize
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, [isOpen, placement, offset]);
|
||||
|
||||
// Handle click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen || !closeOnClickOutside) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
triggerRef.current?.contains(target) ||
|
||||
contentRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, closeOnClickOutside, handleClose]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen || !closeOnEscape) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, closeOnEscape, handleClose]);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
|
||||
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Clone trigger element with event handlers
|
||||
const triggerElement = isValidElement(children)
|
||||
? cloneElement(children as React.ReactElement<any>, {
|
||||
ref: triggerRef,
|
||||
...(trigger === 'click' && {
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
(children as React.ReactElement<any>).props.onClick?.(e);
|
||||
handleToggle();
|
||||
},
|
||||
}),
|
||||
...(trigger === 'hover' && {
|
||||
onMouseEnter: (e: React.MouseEvent) => {
|
||||
(children as React.ReactElement<any>).props.onMouseEnter?.(e);
|
||||
handleOpen();
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent) => {
|
||||
(children as React.ReactElement<any>).props.onMouseLeave?.(e);
|
||||
handleClose();
|
||||
},
|
||||
}),
|
||||
...(trigger === 'focus' && {
|
||||
onFocus: (e: React.FocusEvent) => {
|
||||
(children as React.ReactElement<any>).props.onFocus?.(e);
|
||||
handleOpen();
|
||||
},
|
||||
onBlur: (e: React.FocusEvent) => {
|
||||
(children as React.ReactElement<any>).props.onBlur?.(e);
|
||||
handleClose();
|
||||
},
|
||||
}),
|
||||
})
|
||||
: children;
|
||||
|
||||
// Only render portal in browser
|
||||
const canRenderPortal = typeof window !== 'undefined';
|
||||
|
||||
return (
|
||||
<>
|
||||
{triggerElement}
|
||||
{canRenderPortal &&
|
||||
isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={(node) => {
|
||||
(contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
if (typeof ref === 'function') {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
ref.current = node;
|
||||
}
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
className={cn(popoverContentVariants({ state: 'entered' }), className)}
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
}}
|
||||
onMouseEnter={trigger === 'hover' ? handleOpen : undefined}
|
||||
onMouseLeave={trigger === 'hover' ? handleClose : undefined}
|
||||
{...props}
|
||||
>
|
||||
{hasArrow && (
|
||||
<div className={popoverArrowVariants({ placement })} />
|
||||
)}
|
||||
<div className={popoverBodyVariants({})}>
|
||||
{content}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Popover.displayName = 'Popover';
|
||||
137
packages/ui/src/Popover/types.ts
Normal file
137
packages/ui/src/Popover/types.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import type { HTMLAttributes, ReactNode, RefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Popover placement types
|
||||
*/
|
||||
export type PopoverPlacement =
|
||||
| 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'left'
|
||||
| 'left-start'
|
||||
| 'left-end'
|
||||
| 'right'
|
||||
| 'right-start'
|
||||
| 'right-end';
|
||||
|
||||
/**
|
||||
* Popover trigger types
|
||||
*/
|
||||
export type PopoverTrigger = 'click' | 'hover' | 'focus' | 'manual';
|
||||
|
||||
/**
|
||||
* Popover component props
|
||||
*/
|
||||
export interface PopoverProps extends Omit<HTMLAttributes<HTMLDivElement>, 'content'> {
|
||||
/**
|
||||
* Whether the popover is open (controlled mode)
|
||||
*/
|
||||
open?: boolean;
|
||||
|
||||
/**
|
||||
* Callback when open state changes
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
|
||||
/**
|
||||
* Default open state (uncontrolled mode)
|
||||
* @default false
|
||||
*/
|
||||
defaultOpen?: boolean;
|
||||
|
||||
/**
|
||||
* The trigger element (must accept ref)
|
||||
*/
|
||||
children: ReactNode;
|
||||
|
||||
/**
|
||||
* The popover content
|
||||
*/
|
||||
content: ReactNode;
|
||||
|
||||
/**
|
||||
* Placement of the popover relative to trigger
|
||||
* @default 'bottom'
|
||||
*/
|
||||
placement?: PopoverPlacement;
|
||||
|
||||
/**
|
||||
* How the popover is triggered
|
||||
* @default 'click'
|
||||
*/
|
||||
trigger?: PopoverTrigger;
|
||||
|
||||
/**
|
||||
* Offset from the trigger element in pixels
|
||||
* @default 8
|
||||
*/
|
||||
offset?: number;
|
||||
|
||||
/**
|
||||
* Whether to close when clicking outside
|
||||
* @default true
|
||||
*/
|
||||
closeOnClickOutside?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to close on Escape key
|
||||
* @default true
|
||||
*/
|
||||
closeOnEscape?: boolean;
|
||||
|
||||
/**
|
||||
* Delay before showing (for hover trigger) in milliseconds
|
||||
* @default 0
|
||||
*/
|
||||
showDelay?: number;
|
||||
|
||||
/**
|
||||
* Delay before hiding (for hover trigger) in milliseconds
|
||||
* @default 0
|
||||
*/
|
||||
hideDelay?: number;
|
||||
|
||||
/**
|
||||
* Whether the popover has an arrow
|
||||
* @default false
|
||||
*/
|
||||
hasArrow?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the popover is disabled
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* PopoverContent component props
|
||||
*/
|
||||
export interface PopoverContentProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* PopoverTrigger component props
|
||||
*/
|
||||
export interface PopoverTriggerProps extends HTMLAttributes<HTMLElement> {
|
||||
children: ReactNode;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover ref type
|
||||
*/
|
||||
export type PopoverRef = HTMLDivElement;
|
||||
|
||||
/**
|
||||
* Internal popover state
|
||||
*/
|
||||
export interface PopoverState {
|
||||
isOpen: boolean;
|
||||
triggerRef: RefObject<HTMLElement | null>;
|
||||
contentRef: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
81
packages/ui/src/Popover/variants.ts
Normal file
81
packages/ui/src/Popover/variants.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { createVariants } from '../system/variants';
|
||||
|
||||
/**
|
||||
* Popover content variant definitions
|
||||
*/
|
||||
export const popoverContentVariants = createVariants({
|
||||
base: [
|
||||
// Layout
|
||||
'absolute',
|
||||
'z-[var(--z-popover)]',
|
||||
'min-w-[8rem]',
|
||||
'max-w-[20rem]',
|
||||
// Styling - Sharp corners, blueprint aesthetic
|
||||
'bg-surface border border-border',
|
||||
'rounded-none',
|
||||
// Shadow
|
||||
'shadow-lg',
|
||||
// Animation
|
||||
'transition-all duration-150',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
state: {
|
||||
entering: 'opacity-0 scale-95',
|
||||
entered: 'opacity-100 scale-100',
|
||||
exiting: 'opacity-0 scale-95',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
state: 'entered',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Popover arrow variant definitions
|
||||
*/
|
||||
export const popoverArrowVariants = createVariants({
|
||||
base: [
|
||||
'absolute',
|
||||
'w-2 h-2',
|
||||
'bg-surface border-border',
|
||||
'rotate-45',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
placement: {
|
||||
top: 'bottom-[-5px] border-r border-b',
|
||||
'top-start': 'bottom-[-5px] border-r border-b left-4',
|
||||
'top-end': 'bottom-[-5px] border-r border-b right-4',
|
||||
bottom: 'top-[-5px] border-l border-t',
|
||||
'bottom-start': 'top-[-5px] border-l border-t left-4',
|
||||
'bottom-end': 'top-[-5px] border-l border-t right-4',
|
||||
left: 'right-[-5px] border-r border-t',
|
||||
'left-start': 'right-[-5px] border-r border-t top-4',
|
||||
'left-end': 'right-[-5px] border-r border-t bottom-4',
|
||||
right: 'left-[-5px] border-l border-b',
|
||||
'right-start': 'left-[-5px] border-l border-b top-4',
|
||||
'right-end': 'left-[-5px] border-l border-b bottom-4',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
placement: 'bottom',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Popover body variant definitions
|
||||
*/
|
||||
export const popoverBodyVariants = createVariants({
|
||||
base: [
|
||||
'p-3',
|
||||
'font-mono text-sm',
|
||||
'text-foreground',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
|
@ -9,8 +9,8 @@ export const progressBarTrackVariants = createVariants({
|
|||
'relative overflow-hidden',
|
||||
// Background
|
||||
'bg-surface',
|
||||
// Border
|
||||
'rounded',
|
||||
// Border - SHARP CORNERS
|
||||
'rounded-none',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
|
|
@ -35,10 +35,10 @@ export const progressBarFillVariants = createVariants({
|
|||
base: [
|
||||
// Layout
|
||||
'h-full',
|
||||
// Transition
|
||||
'transition-all duration-300 ease-out',
|
||||
// Border
|
||||
'rounded',
|
||||
// Transition - Fast
|
||||
'transition-all duration-150 ease-out',
|
||||
// Border - SHARP CORNERS
|
||||
'rounded-none',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
|
|
|
|||
236
packages/ui/src/QualityScore/QualityScore.tsx
Normal file
236
packages/ui/src/QualityScore/QualityScore.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@tpmjs/utils/cn';
|
||||
import { forwardRef, useMemo } from 'react';
|
||||
import type { QualityScoreBreakdown, QualityScoreProps, QualityTier } from './types';
|
||||
import {
|
||||
qualityScoreBarFillVariants,
|
||||
qualityScoreBarVariants,
|
||||
qualityScoreBreakdownRowVariants,
|
||||
qualityScoreBreakdownVariants,
|
||||
qualityScoreCircleVariants,
|
||||
qualityScoreTierVariants,
|
||||
qualityScoreVariants,
|
||||
} from './variants';
|
||||
|
||||
/**
|
||||
* Get tier from score percentage
|
||||
*/
|
||||
function getTierFromScore(score: number): QualityTier {
|
||||
if (score >= 80) return 'excellent';
|
||||
if (score >= 60) return 'good';
|
||||
if (score >= 40) return 'fair';
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tier label
|
||||
*/
|
||||
function getTierLabel(tier: QualityTier): string {
|
||||
switch (tier) {
|
||||
case 'excellent':
|
||||
return 'Excellent';
|
||||
case 'good':
|
||||
return 'Good';
|
||||
case 'fair':
|
||||
return 'Fair';
|
||||
case 'poor':
|
||||
return 'Poor';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Breakdown category labels
|
||||
*/
|
||||
const BREAKDOWN_LABELS: Record<keyof QualityScoreBreakdown, string> = {
|
||||
documentation: 'Docs',
|
||||
maintenance: 'Maintenance',
|
||||
popularity: 'Popularity',
|
||||
security: 'Security',
|
||||
tests: 'Tests',
|
||||
};
|
||||
|
||||
/**
|
||||
* QualityScore component
|
||||
*
|
||||
* A component for displaying quality scores with visual indicators.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { QualityScore } from '@tpmjs/ui/QualityScore/QualityScore';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return (
|
||||
* <QualityScore
|
||||
* score={85}
|
||||
* variant="badge"
|
||||
* showTier
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const QualityScore = forwardRef<HTMLDivElement, QualityScoreProps>(
|
||||
(
|
||||
{
|
||||
score,
|
||||
isDecimal = false,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
showTier = true,
|
||||
showScore = true,
|
||||
tierLabel: customTierLabel,
|
||||
breakdown,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// Normalize score to percentage
|
||||
const normalizedScore = useMemo(() => {
|
||||
if (isDecimal) {
|
||||
return Math.round(score * 100);
|
||||
}
|
||||
return Math.round(score);
|
||||
}, [score, isDecimal]);
|
||||
|
||||
const tier = getTierFromScore(normalizedScore);
|
||||
const tierLabel = customTierLabel || getTierLabel(tier);
|
||||
|
||||
// Badge variant
|
||||
if (variant === 'badge') {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(qualityScoreVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={qualityScoreCircleVariants({ size, tier })}
|
||||
style={{ width: 'auto', height: 'auto', padding: '0.25rem 0.5rem', borderRadius: 0 }}
|
||||
>
|
||||
{showScore && normalizedScore}
|
||||
</div>
|
||||
{showTier && (
|
||||
<span className={qualityScoreTierVariants({ size, tier })}>
|
||||
{tierLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline variant
|
||||
if (variant === 'inline') {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(qualityScoreVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
>
|
||||
{showScore && (
|
||||
<span className={qualityScoreTierVariants({ size, tier })}>
|
||||
{normalizedScore}
|
||||
</span>
|
||||
)}
|
||||
{showTier && (
|
||||
<span className="text-foreground-muted">
|
||||
({tierLabel})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Detailed variant
|
||||
if (variant === 'detailed') {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(qualityScoreVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Main score display */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={qualityScoreCircleVariants({ size, tier })}>
|
||||
{showScore && normalizedScore}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{showTier && (
|
||||
<span className={qualityScoreTierVariants({ size, tier })}>
|
||||
{tierLabel}
|
||||
</span>
|
||||
)}
|
||||
{showScore && (
|
||||
<span className="text-foreground-muted text-xs">
|
||||
out of 100
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown */}
|
||||
{breakdown && (
|
||||
<div className={qualityScoreBreakdownVariants({})}>
|
||||
{(Object.keys(breakdown) as Array<keyof QualityScoreBreakdown>).map(
|
||||
(key) => {
|
||||
const value = breakdown[key];
|
||||
if (value === undefined) return null;
|
||||
|
||||
const percentage = Math.round(value * 100);
|
||||
const breakdownTier = getTierFromScore(percentage);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={qualityScoreBreakdownRowVariants({ size })}
|
||||
>
|
||||
<span className="w-20 text-right">
|
||||
{BREAKDOWN_LABELS[key]}
|
||||
</span>
|
||||
<div className={qualityScoreBarVariants({ size })}>
|
||||
<div
|
||||
className={qualityScoreBarFillVariants({ tier: breakdownTier })}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right">{percentage}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default variant
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(qualityScoreVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div className={qualityScoreCircleVariants({ size, tier })}>
|
||||
{showScore && normalizedScore}
|
||||
</div>
|
||||
{showTier && (
|
||||
<div className="flex flex-col">
|
||||
<span className={qualityScoreTierVariants({ size, tier })}>
|
||||
{tierLabel}
|
||||
</span>
|
||||
<div className={qualityScoreBarVariants({ size })}>
|
||||
<div
|
||||
className={qualityScoreBarFillVariants({ tier })}
|
||||
style={{ width: `${normalizedScore}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
QualityScore.displayName = 'QualityScore';
|
||||
101
packages/ui/src/QualityScore/types.ts
Normal file
101
packages/ui/src/QualityScore/types.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
/**
|
||||
* Quality tier types
|
||||
*/
|
||||
export type QualityTier = 'excellent' | 'good' | 'fair' | 'poor';
|
||||
|
||||
/**
|
||||
* QualityScore size types
|
||||
*/
|
||||
export type QualityScoreSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* QualityScore variant types
|
||||
*/
|
||||
export type QualityScoreVariant = 'default' | 'badge' | 'inline' | 'detailed';
|
||||
|
||||
/**
|
||||
* QualityScore component props
|
||||
*/
|
||||
export interface QualityScoreProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Score value (0-100 or 0-1)
|
||||
*/
|
||||
score: number;
|
||||
|
||||
/**
|
||||
* Whether score is in decimal format (0-1) vs percentage (0-100)
|
||||
* @default false
|
||||
*/
|
||||
isDecimal?: boolean;
|
||||
|
||||
/**
|
||||
* Size variant
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: QualityScoreSize;
|
||||
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: QualityScoreVariant;
|
||||
|
||||
/**
|
||||
* Whether to show the tier label
|
||||
* @default true
|
||||
*/
|
||||
showTier?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to show the numeric score
|
||||
* @default true
|
||||
*/
|
||||
showScore?: boolean;
|
||||
|
||||
/**
|
||||
* Custom tier label
|
||||
*/
|
||||
tierLabel?: string;
|
||||
|
||||
/**
|
||||
* Breakdown of score components (for detailed variant)
|
||||
*/
|
||||
breakdown?: QualityScoreBreakdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality score breakdown
|
||||
*/
|
||||
export interface QualityScoreBreakdown {
|
||||
/**
|
||||
* Documentation score (0-1)
|
||||
*/
|
||||
documentation?: number;
|
||||
|
||||
/**
|
||||
* Maintenance score (0-1)
|
||||
*/
|
||||
maintenance?: number;
|
||||
|
||||
/**
|
||||
* Popularity score (0-1)
|
||||
*/
|
||||
popularity?: number;
|
||||
|
||||
/**
|
||||
* Security score (0-1)
|
||||
*/
|
||||
security?: number;
|
||||
|
||||
/**
|
||||
* Test coverage score (0-1)
|
||||
*/
|
||||
tests?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* QualityScore ref type
|
||||
*/
|
||||
export type QualityScoreRef = HTMLDivElement;
|
||||
175
packages/ui/src/QualityScore/variants.ts
Normal file
175
packages/ui/src/QualityScore/variants.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { createVariants } from '../system/variants';
|
||||
|
||||
/**
|
||||
* QualityScore container variant definitions
|
||||
*/
|
||||
export const qualityScoreVariants = createVariants({
|
||||
base: [
|
||||
'inline-flex items-center',
|
||||
'font-mono',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'gap-2',
|
||||
badge: 'gap-1.5 px-2 py-1 border border-border rounded-none',
|
||||
inline: 'gap-1',
|
||||
detailed: 'flex-col items-start gap-2',
|
||||
},
|
||||
size: {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* QualityScore circle variant definitions
|
||||
*/
|
||||
export const qualityScoreCircleVariants = createVariants({
|
||||
base: [
|
||||
'relative',
|
||||
'flex items-center justify-center',
|
||||
'rounded-full',
|
||||
'border-2',
|
||||
'font-mono font-semibold',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'w-8 h-8 text-xs',
|
||||
md: 'w-10 h-10 text-sm',
|
||||
lg: 'w-14 h-14 text-base',
|
||||
},
|
||||
tier: {
|
||||
excellent: 'border-success text-success bg-success/10',
|
||||
good: 'border-primary text-primary bg-primary/10',
|
||||
fair: 'border-warning text-warning bg-warning/10',
|
||||
poor: 'border-error text-error bg-error/10',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
tier: 'fair',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* QualityScore tier label variant definitions
|
||||
*/
|
||||
export const qualityScoreTierVariants = createVariants({
|
||||
base: [
|
||||
'font-mono font-medium',
|
||||
'uppercase tracking-wider',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'text-[10px]',
|
||||
md: 'text-xs',
|
||||
lg: 'text-sm',
|
||||
},
|
||||
tier: {
|
||||
excellent: 'text-success',
|
||||
good: 'text-primary',
|
||||
fair: 'text-warning',
|
||||
poor: 'text-error',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
tier: 'fair',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* QualityScore bar variant definitions
|
||||
*/
|
||||
export const qualityScoreBarVariants = createVariants({
|
||||
base: [
|
||||
'h-1.5',
|
||||
'bg-accent/20',
|
||||
'rounded-none',
|
||||
'overflow-hidden',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'w-16',
|
||||
md: 'w-20',
|
||||
lg: 'w-24',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* QualityScore bar fill variant definitions
|
||||
*/
|
||||
export const qualityScoreBarFillVariants = createVariants({
|
||||
base: [
|
||||
'h-full',
|
||||
'transition-all duration-300',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
tier: {
|
||||
excellent: 'bg-success',
|
||||
good: 'bg-primary',
|
||||
fair: 'bg-warning',
|
||||
poor: 'bg-error',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
tier: 'fair',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* QualityScore breakdown container variant definitions
|
||||
*/
|
||||
export const qualityScoreBreakdownVariants = createVariants({
|
||||
base: [
|
||||
'w-full',
|
||||
'space-y-1.5',
|
||||
'pt-2',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* QualityScore breakdown row variant definitions
|
||||
*/
|
||||
export const qualityScoreBreakdownRowVariants = createVariants({
|
||||
base: [
|
||||
'flex items-center gap-2',
|
||||
'text-foreground-muted',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'text-[10px]',
|
||||
md: 'text-xs',
|
||||
lg: 'text-sm',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
319
packages/ui/src/Skeleton/Skeleton.tsx
Normal file
319
packages/ui/src/Skeleton/Skeleton.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@tpmjs/utils/cn';
|
||||
import { forwardRef } from 'react';
|
||||
import type {
|
||||
SkeletonAvatarProps,
|
||||
SkeletonCardProps,
|
||||
SkeletonProps,
|
||||
SkeletonTextProps,
|
||||
} from './types';
|
||||
import {
|
||||
skeletonAvatarVariants,
|
||||
skeletonCardImageVariants,
|
||||
skeletonCardVariants,
|
||||
skeletonTextContainerVariants,
|
||||
skeletonVariants,
|
||||
} from './variants';
|
||||
|
||||
/**
|
||||
* Normalize dimension to CSS value
|
||||
*/
|
||||
function normalizeDimension(value: string | number | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return typeof value === 'number' ? `${value}px` : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton component
|
||||
*
|
||||
* A placeholder component for loading states.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Skeleton } from '@tpmjs/ui/Skeleton/Skeleton';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return (
|
||||
* <div>
|
||||
* <Skeleton variant="text" width="60%" />
|
||||
* <Skeleton variant="text" width="80%" />
|
||||
* <Skeleton variant="circular" width={40} height={40} />
|
||||
* <Skeleton variant="rectangular" width="100%" height={200} />
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Skeleton = forwardRef<HTMLDivElement, SkeletonProps>(
|
||||
(
|
||||
{
|
||||
variant = 'text',
|
||||
animation = 'pulse',
|
||||
width,
|
||||
height,
|
||||
lines = 1,
|
||||
gap = '0.5rem',
|
||||
lastLineShort = false,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// Handle multiple lines for text variant
|
||||
if (variant === 'text' && lines > 1) {
|
||||
const gapValue = normalizeDimension(gap);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(skeletonTextContainerVariants({}), className)}
|
||||
style={{ gap: gapValue, ...style }}
|
||||
{...props}
|
||||
>
|
||||
{Array.from({ length: lines }).map((_, index) => {
|
||||
const isLast = index === lines - 1;
|
||||
const lineWidth = isLast && lastLineShort ? '60%' : width || '100%';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={skeletonVariants({ variant, animation })}
|
||||
style={{
|
||||
width: normalizeDimension(lineWidth),
|
||||
height: normalizeDimension(height),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(skeletonVariants({ variant, animation }), className)}
|
||||
style={{
|
||||
width: normalizeDimension(width),
|
||||
height: normalizeDimension(height),
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Skeleton.displayName = 'Skeleton';
|
||||
|
||||
/**
|
||||
* SkeletonText component
|
||||
*
|
||||
* A preset for text loading placeholders.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { SkeletonText } from '@tpmjs/ui/Skeleton/Skeleton';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return <SkeletonText lines={3} />;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const SkeletonText = forwardRef<HTMLDivElement, SkeletonTextProps>(
|
||||
(
|
||||
{
|
||||
lines = 3,
|
||||
gap = '0.5rem',
|
||||
width,
|
||||
animation = 'pulse',
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const gapValue = normalizeDimension(gap);
|
||||
const widths = Array.isArray(width) ? width : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(skeletonTextContainerVariants({}), className)}
|
||||
style={{ gap: gapValue, ...style }}
|
||||
{...props}
|
||||
>
|
||||
{Array.from({ length: lines }).map((_, index) => {
|
||||
let lineWidth: string | number = '100%';
|
||||
|
||||
if (widths && widths[index] !== undefined) {
|
||||
lineWidth = widths[index];
|
||||
} else if (!Array.isArray(width) && width !== undefined) {
|
||||
lineWidth = width;
|
||||
} else if (index === lines - 1) {
|
||||
// Make last line shorter by default
|
||||
lineWidth = '60%';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={skeletonVariants({ variant: 'text', animation })}
|
||||
style={{ width: normalizeDimension(lineWidth) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SkeletonText.displayName = 'SkeletonText';
|
||||
|
||||
/**
|
||||
* SkeletonAvatar component
|
||||
*
|
||||
* A preset for avatar loading placeholders.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { SkeletonAvatar } from '@tpmjs/ui/Skeleton/Skeleton';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return <SkeletonAvatar size="lg" />;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const SkeletonAvatar = forwardRef<HTMLDivElement, SkeletonAvatarProps>(
|
||||
({ size = 'md', animation = 'pulse', className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(skeletonAvatarVariants({ size, animation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
SkeletonAvatar.displayName = 'SkeletonAvatar';
|
||||
|
||||
/**
|
||||
* SkeletonCard component
|
||||
*
|
||||
* A preset for card loading placeholders.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { SkeletonCard } from '@tpmjs/ui/Skeleton/Skeleton';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return <SkeletonCard showImage lines={3} />;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const SkeletonCard = forwardRef<HTMLDivElement, SkeletonCardProps>(
|
||||
(
|
||||
{
|
||||
showImage = true,
|
||||
lines = 3,
|
||||
animation = 'pulse',
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(skeletonCardVariants({}), className)}
|
||||
{...props}
|
||||
>
|
||||
{showImage && (
|
||||
<div className={skeletonCardImageVariants({ animation })} />
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{/* Title skeleton */}
|
||||
<div
|
||||
className={skeletonVariants({ variant: 'text', animation })}
|
||||
style={{ width: '70%', height: '1.25rem' }}
|
||||
/>
|
||||
{/* Content skeletons */}
|
||||
{Array.from({ length: lines }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={skeletonVariants({ variant: 'text', animation })}
|
||||
style={{
|
||||
width: index === lines - 1 ? '50%' : '100%',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
SkeletonCard.displayName = 'SkeletonCard';
|
||||
|
||||
/**
|
||||
* SkeletonTable component
|
||||
*
|
||||
* A preset for table loading placeholders.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { SkeletonTable } from '@tpmjs/ui/Skeleton/Skeleton';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return <SkeletonTable rows={5} columns={4} />;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface SkeletonTableProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
animation?: 'pulse' | 'wave' | 'none';
|
||||
}
|
||||
|
||||
export const SkeletonTable = forwardRef<HTMLDivElement, SkeletonTableProps>(
|
||||
({ rows = 5, columns = 4, animation = 'pulse', className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('w-full', className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex gap-4 py-3 border-b border-border">
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<div
|
||||
key={`header-${colIndex}`}
|
||||
className={cn(
|
||||
skeletonVariants({ variant: 'text', animation }),
|
||||
'flex-1'
|
||||
)}
|
||||
style={{ height: '1rem' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<div
|
||||
key={`row-${rowIndex}`}
|
||||
className="flex gap-4 py-3 border-b border-border last:border-b-0"
|
||||
>
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<div
|
||||
key={`cell-${rowIndex}-${colIndex}`}
|
||||
className={cn(
|
||||
skeletonVariants({ variant: 'text', animation }),
|
||||
'flex-1'
|
||||
)}
|
||||
style={{ height: '1rem' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
SkeletonTable.displayName = 'SkeletonTable';
|
||||
129
packages/ui/src/Skeleton/types.ts
Normal file
129
packages/ui/src/Skeleton/types.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
/**
|
||||
* Skeleton variant types
|
||||
*/
|
||||
export type SkeletonVariant = 'text' | 'circular' | 'rectangular' | 'rounded';
|
||||
|
||||
/**
|
||||
* Skeleton animation types
|
||||
*/
|
||||
export type SkeletonAnimation = 'pulse' | 'wave' | 'none';
|
||||
|
||||
/**
|
||||
* Skeleton component props
|
||||
*/
|
||||
export interface SkeletonProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Shape variant
|
||||
* @default 'text'
|
||||
*/
|
||||
variant?: SkeletonVariant;
|
||||
|
||||
/**
|
||||
* Animation type
|
||||
* @default 'pulse'
|
||||
*/
|
||||
animation?: SkeletonAnimation;
|
||||
|
||||
/**
|
||||
* Width (CSS value)
|
||||
*/
|
||||
width?: string | number;
|
||||
|
||||
/**
|
||||
* Height (CSS value)
|
||||
*/
|
||||
height?: string | number;
|
||||
|
||||
/**
|
||||
* Number of skeleton lines (for text variant)
|
||||
* @default 1
|
||||
*/
|
||||
lines?: number;
|
||||
|
||||
/**
|
||||
* Gap between lines (for text variant)
|
||||
* @default '0.5rem'
|
||||
*/
|
||||
gap?: string | number;
|
||||
|
||||
/**
|
||||
* Whether the last line should be shorter (for text variant)
|
||||
* @default false
|
||||
*/
|
||||
lastLineShort?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SkeletonText component props
|
||||
*/
|
||||
export interface SkeletonTextProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Number of lines
|
||||
* @default 3
|
||||
*/
|
||||
lines?: number;
|
||||
|
||||
/**
|
||||
* Gap between lines
|
||||
* @default '0.5rem'
|
||||
*/
|
||||
gap?: string | number;
|
||||
|
||||
/**
|
||||
* Width of each line (can be string, number, or array)
|
||||
*/
|
||||
width?: string | number | (string | number)[];
|
||||
|
||||
/**
|
||||
* Animation type
|
||||
* @default 'pulse'
|
||||
*/
|
||||
animation?: SkeletonAnimation;
|
||||
}
|
||||
|
||||
/**
|
||||
* SkeletonAvatar component props
|
||||
*/
|
||||
export interface SkeletonAvatarProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Size of the avatar
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
/**
|
||||
* Animation type
|
||||
* @default 'pulse'
|
||||
*/
|
||||
animation?: SkeletonAnimation;
|
||||
}
|
||||
|
||||
/**
|
||||
* SkeletonCard component props
|
||||
*/
|
||||
export interface SkeletonCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Whether to show an image placeholder
|
||||
* @default true
|
||||
*/
|
||||
showImage?: boolean;
|
||||
|
||||
/**
|
||||
* Number of text lines
|
||||
* @default 3
|
||||
*/
|
||||
lines?: number;
|
||||
|
||||
/**
|
||||
* Animation type
|
||||
* @default 'pulse'
|
||||
*/
|
||||
animation?: SkeletonAnimation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton ref type
|
||||
*/
|
||||
export type SkeletonRef = HTMLDivElement;
|
||||
111
packages/ui/src/Skeleton/variants.ts
Normal file
111
packages/ui/src/Skeleton/variants.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { createVariants } from '../system/variants';
|
||||
|
||||
/**
|
||||
* Skeleton base variant definitions
|
||||
*/
|
||||
export const skeletonVariants = createVariants({
|
||||
base: [
|
||||
'bg-accent/20',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
text: 'h-4 rounded-none',
|
||||
circular: 'rounded-full',
|
||||
rectangular: 'rounded-none',
|
||||
rounded: 'rounded-sm',
|
||||
},
|
||||
animation: {
|
||||
pulse: 'animate-pulse',
|
||||
wave: 'skeleton-wave',
|
||||
none: '',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'text',
|
||||
animation: 'pulse',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Skeleton text container variant definitions
|
||||
*/
|
||||
export const skeletonTextContainerVariants = createVariants({
|
||||
base: [
|
||||
'flex flex-col',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Skeleton avatar variant definitions
|
||||
*/
|
||||
export const skeletonAvatarVariants = createVariants({
|
||||
base: [
|
||||
'rounded-full',
|
||||
'bg-accent/20',
|
||||
'flex-shrink-0',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'w-8 h-8',
|
||||
md: 'w-10 h-10',
|
||||
lg: 'w-12 h-12',
|
||||
xl: 'w-16 h-16',
|
||||
},
|
||||
animation: {
|
||||
pulse: 'animate-pulse',
|
||||
wave: 'skeleton-wave',
|
||||
none: '',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
animation: 'pulse',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Skeleton card variant definitions
|
||||
*/
|
||||
export const skeletonCardVariants = createVariants({
|
||||
base: [
|
||||
'border border-border',
|
||||
'rounded-none',
|
||||
'p-4',
|
||||
'space-y-4',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Skeleton card image variant definitions
|
||||
*/
|
||||
export const skeletonCardImageVariants = createVariants({
|
||||
base: [
|
||||
'w-full h-32',
|
||||
'bg-accent/20',
|
||||
'rounded-none',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
animation: {
|
||||
pulse: 'animate-pulse',
|
||||
wave: 'skeleton-wave',
|
||||
none: '',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
animation: 'pulse',
|
||||
},
|
||||
});
|
||||
|
|
@ -18,8 +18,8 @@ export const tabsContainerVariants = createVariants({
|
|||
lg: 'gap-1',
|
||||
},
|
||||
variant: {
|
||||
default: 'border-b border-border',
|
||||
blueprint: 'border-b border-dotted border-border',
|
||||
default: 'border-b border-dashed border-border',
|
||||
blueprint: 'border-b border-dashed border-border',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -38,10 +38,10 @@ export const tabButtonVariants = createVariants({
|
|||
base: [
|
||||
// Display
|
||||
'inline-flex items-center gap-2',
|
||||
// Font
|
||||
'font-medium whitespace-nowrap',
|
||||
// Transition
|
||||
'transition-colors duration-200',
|
||||
// Font - Monospace, lowercase
|
||||
'font-mono font-medium lowercase whitespace-nowrap',
|
||||
// Transition - Fast, subtle
|
||||
'transition-colors duration-150',
|
||||
// Cursor
|
||||
'cursor-pointer',
|
||||
].join(' '),
|
||||
|
|
@ -59,7 +59,7 @@ export const tabButtonVariants = createVariants({
|
|||
},
|
||||
variant: {
|
||||
default: 'border-b-2',
|
||||
blueprint: 'border-b-2 border-dotted',
|
||||
blueprint: 'border-b-2 border-dashed',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -83,12 +83,12 @@ export const tabCountVariants = createVariants({
|
|||
'min-w-[1.25rem] h-5',
|
||||
// Padding
|
||||
'px-1.5',
|
||||
// Font
|
||||
'text-xs font-medium tabular-nums',
|
||||
// Font - Monospace
|
||||
'font-mono text-xs font-medium tabular-nums',
|
||||
// Background
|
||||
'bg-surface-elevated',
|
||||
// Border
|
||||
'rounded-full',
|
||||
// Border - Sharp corners
|
||||
'rounded-none',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
|
|
|
|||
|
|
@ -8,16 +8,16 @@ export const textareaVariants = createVariants({
|
|||
base: [
|
||||
// Layout
|
||||
'flex w-full',
|
||||
// Typography
|
||||
'font-sans',
|
||||
// Borders & Radius
|
||||
'rounded-md border',
|
||||
// Typography - Monospace
|
||||
'font-mono',
|
||||
// Borders & Radius - SHARP CORNERS
|
||||
'rounded-none border',
|
||||
// Background - Pure white to stand out
|
||||
'bg-surface',
|
||||
// Transitions
|
||||
'transition-base',
|
||||
// Focus
|
||||
'focus-ring',
|
||||
// Transitions - Fast, subtle
|
||||
'transition-colors duration-150',
|
||||
// Focus - Copper accent
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
||||
// Placeholder
|
||||
'placeholder:text-foreground-tertiary',
|
||||
// Disabled state
|
||||
|
|
|
|||
357
packages/ui/src/Toast/Toast.tsx
Normal file
357
packages/ui/src/Toast/Toast.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@tpmjs/utils/cn';
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import type {
|
||||
ToastContainerProps,
|
||||
ToastContextValue,
|
||||
ToastPosition,
|
||||
ToastProps,
|
||||
ToastState,
|
||||
ToastVariant,
|
||||
} from './types';
|
||||
import {
|
||||
toastActionVariants,
|
||||
toastCloseButtonVariants,
|
||||
toastContainerVariants,
|
||||
toastContentVariants,
|
||||
toastDescriptionVariants,
|
||||
toastIconVariants,
|
||||
toastTitleVariants,
|
||||
toastVariants,
|
||||
} from './variants';
|
||||
|
||||
/**
|
||||
* Get the default icon for a toast variant
|
||||
*/
|
||||
function getVariantIcon(variant: ToastVariant): 'checkCircle' | 'xCircle' | 'alertTriangle' | 'info' | 'bell' {
|
||||
switch (variant) {
|
||||
case 'success':
|
||||
return 'checkCircle';
|
||||
case 'error':
|
||||
return 'xCircle';
|
||||
case 'warning':
|
||||
return 'alertTriangle';
|
||||
case 'info':
|
||||
return 'info';
|
||||
default:
|
||||
return 'bell';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast component
|
||||
*
|
||||
* A notification component that displays brief messages to the user.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Toast } from '@tpmjs/ui/Toast/Toast';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const [open, setOpen] = useState(true);
|
||||
*
|
||||
* return (
|
||||
* <Toast
|
||||
* open={open}
|
||||
* onClose={() => setOpen(false)}
|
||||
* title="Success"
|
||||
* description="Your changes have been saved."
|
||||
* variant="success"
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Toast = forwardRef<HTMLDivElement, ToastProps>(
|
||||
(
|
||||
{
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
variant = 'default',
|
||||
action,
|
||||
duration = 5000,
|
||||
showCloseButton = true,
|
||||
icon,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
// Auto-dismiss timer
|
||||
useEffect(() => {
|
||||
if (!open || duration === 0) return;
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
onClose();
|
||||
}, duration);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [open, duration, onClose]);
|
||||
|
||||
// Pause timer on hover
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (duration === 0) return;
|
||||
timerRef.current = setTimeout(() => {
|
||||
onClose();
|
||||
}, duration);
|
||||
}, [duration, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const defaultIcon = getVariantIcon(variant);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className={cn(toastVariants({ variant, state: 'entered' }), className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className={toastIconVariants({ variant })}>
|
||||
{icon ?? <Icon icon={defaultIcon} size="sm" />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={toastContentVariants()}>
|
||||
{title && <div className={toastTitleVariants()}>{title}</div>}
|
||||
{description && (
|
||||
<div className={toastDescriptionVariants()}>{description}</div>
|
||||
)}
|
||||
{action && <div className={toastActionVariants()}>{action}</div>}
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={toastCloseButtonVariants()}
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<Icon icon="x" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Toast.displayName = 'Toast';
|
||||
|
||||
/**
|
||||
* ToastContainer component
|
||||
*
|
||||
* A container that positions toast notifications on the screen.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { ToastContainer, Toast } from '@tpmjs/ui/Toast/Toast';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return (
|
||||
* <ToastContainer position="top-right">
|
||||
* <Toast open={true} onClose={() => {}} title="Hello" />
|
||||
* </ToastContainer>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const ToastContainer = forwardRef<HTMLDivElement, ToastContainerProps>(
|
||||
({ position = 'bottom-right', children, className, ...props }, ref) => {
|
||||
// Only render in browser (for SSR compatibility)
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(toastContainerVariants({ position }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ToastContainer.displayName = 'ToastContainer';
|
||||
|
||||
// Toast context for programmatic usage
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Toast provider props
|
||||
*/
|
||||
export interface ToastProviderProps {
|
||||
children: React.ReactNode;
|
||||
position?: ToastPosition;
|
||||
maxToasts?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ToastProvider component
|
||||
*
|
||||
* Provides toast functionality to the application.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { ToastProvider, useToast } from '@tpmjs/ui/Toast/Toast';
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <ToastProvider position="bottom-right">
|
||||
* <MyComponent />
|
||||
* </ToastProvider>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const { toast, dismiss } = useToast();
|
||||
*
|
||||
* return (
|
||||
* <button onClick={() => toast({ title: 'Hello', variant: 'success' })}>
|
||||
* Show Toast
|
||||
* </button>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function ToastProvider({
|
||||
children,
|
||||
position = 'bottom-right',
|
||||
maxToasts = 5,
|
||||
}: ToastProviderProps) {
|
||||
const [toasts, setToasts] = useState<ToastState[]>([]);
|
||||
|
||||
const toast = useCallback(
|
||||
(props: Omit<ToastProps, 'open' | 'onClose'>): string => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const newToast: ToastState = {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
};
|
||||
|
||||
setToasts((prev) => {
|
||||
const updated = [...prev, newToast];
|
||||
// Remove oldest toasts if exceeding max
|
||||
if (updated.length > maxToasts) {
|
||||
return updated.slice(-maxToasts);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
return id;
|
||||
},
|
||||
[maxToasts]
|
||||
);
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setToasts((prev) =>
|
||||
prev.map((t) => (t.id === id ? { ...t, open: false } : t))
|
||||
);
|
||||
// Remove from DOM after animation
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const dismissAll = useCallback(() => {
|
||||
setToasts((prev) => prev.map((t) => ({ ...t, open: false })));
|
||||
setTimeout(() => {
|
||||
setToasts([]);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
toast,
|
||||
dismiss,
|
||||
dismissAll,
|
||||
}),
|
||||
[toast, dismiss, dismissAll]
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<ToastContainer position={position}>
|
||||
{toasts.map((t) => (
|
||||
<Toast
|
||||
key={t.id}
|
||||
open={t.open}
|
||||
onClose={() => dismiss(t.id)}
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
variant={t.variant}
|
||||
action={t.action}
|
||||
duration={t.duration}
|
||||
showCloseButton={t.showCloseButton}
|
||||
icon={t.icon}
|
||||
/>
|
||||
))}
|
||||
</ToastContainer>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useToast hook
|
||||
*
|
||||
* Hook to access toast functionality from ToastProvider.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { toast, dismiss, dismissAll } = useToast();
|
||||
*
|
||||
* // Show a success toast
|
||||
* const id = toast({
|
||||
* title: 'Success',
|
||||
* description: 'Your changes have been saved.',
|
||||
* variant: 'success',
|
||||
* });
|
||||
*
|
||||
* // Dismiss a specific toast
|
||||
* dismiss(id);
|
||||
*
|
||||
* // Dismiss all toasts
|
||||
* dismissAll();
|
||||
* ```
|
||||
*/
|
||||
export function useToast(): ToastContextValue {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
108
packages/ui/src/Toast/types.ts
Normal file
108
packages/ui/src/Toast/types.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Toast variant types
|
||||
*/
|
||||
export type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
/**
|
||||
* Toast position types
|
||||
*/
|
||||
export type ToastPosition =
|
||||
| 'top-left'
|
||||
| 'top-center'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-center'
|
||||
| 'bottom-right';
|
||||
|
||||
/**
|
||||
* Toast component props
|
||||
*/
|
||||
export interface ToastProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
/**
|
||||
* Whether the toast is visible
|
||||
*/
|
||||
open: boolean;
|
||||
|
||||
/**
|
||||
* Callback when the toast should close
|
||||
*/
|
||||
onClose: () => void;
|
||||
|
||||
/**
|
||||
* Toast title
|
||||
*/
|
||||
title?: ReactNode;
|
||||
|
||||
/**
|
||||
* Toast description/message
|
||||
*/
|
||||
description?: ReactNode;
|
||||
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: ToastVariant;
|
||||
|
||||
/**
|
||||
* Action button (optional)
|
||||
*/
|
||||
action?: ReactNode;
|
||||
|
||||
/**
|
||||
* Auto-dismiss duration in milliseconds (0 = no auto-dismiss)
|
||||
* @default 5000
|
||||
*/
|
||||
duration?: number;
|
||||
|
||||
/**
|
||||
* Whether to show the close button
|
||||
* @default true
|
||||
*/
|
||||
showCloseButton?: boolean;
|
||||
|
||||
/**
|
||||
* Icon to display (auto-detected from variant if not provided)
|
||||
*/
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ToastContainer component props
|
||||
*/
|
||||
export interface ToastContainerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Position of the toast container
|
||||
* @default 'bottom-right'
|
||||
*/
|
||||
position?: ToastPosition;
|
||||
|
||||
/**
|
||||
* Children (Toast components)
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast context for managing toasts
|
||||
*/
|
||||
export interface ToastContextValue {
|
||||
toast: (props: Omit<ToastProps, 'open' | 'onClose'>) => string;
|
||||
dismiss: (id: string) => void;
|
||||
dismissAll: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal toast state
|
||||
*/
|
||||
export interface ToastState extends Omit<ToastProps, 'open' | 'onClose'> {
|
||||
id: string;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast ref type
|
||||
*/
|
||||
export type ToastRef = HTMLDivElement;
|
||||
166
packages/ui/src/Toast/variants.ts
Normal file
166
packages/ui/src/Toast/variants.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { createVariants } from '../system/variants';
|
||||
|
||||
/**
|
||||
* Toast container variant definitions (holds all toasts)
|
||||
*/
|
||||
export const toastContainerVariants = createVariants({
|
||||
base: [
|
||||
'fixed',
|
||||
'z-[var(--z-toast)]',
|
||||
'flex flex-col gap-3',
|
||||
'p-4',
|
||||
'pointer-events-none',
|
||||
'max-h-screen overflow-hidden',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
position: {
|
||||
'top-left': 'top-0 left-0 items-start',
|
||||
'top-center': 'top-0 left-1/2 -translate-x-1/2 items-center',
|
||||
'top-right': 'top-0 right-0 items-end',
|
||||
'bottom-left': 'bottom-0 left-0 items-start',
|
||||
'bottom-center': 'bottom-0 left-1/2 -translate-x-1/2 items-center',
|
||||
'bottom-right': 'bottom-0 right-0 items-end',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
position: 'bottom-right',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Toast variant definitions
|
||||
*/
|
||||
export const toastVariants = createVariants({
|
||||
base: [
|
||||
// Layout
|
||||
'relative w-full max-w-sm',
|
||||
'flex items-start gap-3',
|
||||
'p-4',
|
||||
// Styling - Sharp corners, blueprint aesthetic
|
||||
'bg-surface border border-border',
|
||||
'rounded-none',
|
||||
// Shadow
|
||||
'shadow-lg',
|
||||
// Animation
|
||||
'transition-all duration-200',
|
||||
// Pointer events
|
||||
'pointer-events-auto',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-border',
|
||||
success: 'border-success bg-success/5',
|
||||
error: 'border-error bg-error/5',
|
||||
warning: 'border-warning bg-warning/5',
|
||||
info: 'border-primary bg-primary/5',
|
||||
},
|
||||
state: {
|
||||
entering: 'opacity-0 translate-x-4',
|
||||
entered: 'opacity-100 translate-x-0',
|
||||
exiting: 'opacity-0 translate-x-4',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
state: 'entered',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Toast icon variant definitions
|
||||
*/
|
||||
export const toastIconVariants = createVariants({
|
||||
base: ['flex-shrink-0', 'mt-0.5'].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'text-foreground-muted',
|
||||
success: 'text-success',
|
||||
error: 'text-error',
|
||||
warning: 'text-warning',
|
||||
info: 'text-primary',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Toast content variant definitions
|
||||
*/
|
||||
export const toastContentVariants = createVariants({
|
||||
base: ['flex-1 min-w-0'].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Toast title variant definitions
|
||||
*/
|
||||
export const toastTitleVariants = createVariants({
|
||||
base: [
|
||||
'font-mono font-semibold text-sm',
|
||||
'text-foreground',
|
||||
'lowercase',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Toast description variant definitions
|
||||
*/
|
||||
export const toastDescriptionVariants = createVariants({
|
||||
base: [
|
||||
'font-mono text-sm',
|
||||
'text-foreground-muted',
|
||||
'mt-1',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Toast close button variant definitions
|
||||
*/
|
||||
export const toastCloseButtonVariants = createVariants({
|
||||
base: [
|
||||
'flex-shrink-0',
|
||||
'p-1 -m-1',
|
||||
'text-foreground-muted',
|
||||
'hover:text-foreground hover:bg-accent/10',
|
||||
'transition-colors duration-150',
|
||||
'rounded-none',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Toast action variant definitions
|
||||
*/
|
||||
export const toastActionVariants = createVariants({
|
||||
base: [
|
||||
'mt-2',
|
||||
'flex gap-2',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
257
packages/ui/src/ToolCard/ToolCard.tsx
Normal file
257
packages/ui/src/ToolCard/ToolCard.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@tpmjs/utils/cn';
|
||||
import { forwardRef, useMemo } from 'react';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import type { ToolCardProps } from './types';
|
||||
import {
|
||||
toolCardActionVariants,
|
||||
toolCardDescriptionVariants,
|
||||
toolCardHeaderVariants,
|
||||
toolCardIconVariants,
|
||||
toolCardMetaItemVariants,
|
||||
toolCardMetaVariants,
|
||||
toolCardOfficialBadgeVariants,
|
||||
toolCardTierBadgeVariants,
|
||||
toolCardTitleVariants,
|
||||
toolCardVariants,
|
||||
toolCardVersionVariants,
|
||||
} from './variants';
|
||||
|
||||
/**
|
||||
* Format download count
|
||||
*/
|
||||
function formatDownloads(count: number): string {
|
||||
if (count >= 1_000_000) {
|
||||
return `${(count / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (count >= 1_000) {
|
||||
return `${(count / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time
|
||||
*/
|
||||
function formatRelativeTime(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'today';
|
||||
if (diffDays === 1) return 'yesterday';
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
|
||||
return `${Math.floor(diffDays / 365)}y ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ToolCard component
|
||||
*
|
||||
* A card component for displaying tool/package information.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { ToolCard } from '@tpmjs/ui/ToolCard/ToolCard';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return (
|
||||
* <ToolCard
|
||||
* name="@tpmjs/core"
|
||||
* displayName="TPMJS Core"
|
||||
* version="1.0.0"
|
||||
* description="Core utilities for TPMJS tools"
|
||||
* tier="rich"
|
||||
* qualityScore={85}
|
||||
* downloads={50000}
|
||||
* isOfficial
|
||||
* href="/tool/tpmjs-core"
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const ToolCard = forwardRef<HTMLDivElement, ToolCardProps>(
|
||||
(
|
||||
{
|
||||
name,
|
||||
displayName,
|
||||
version,
|
||||
description,
|
||||
author,
|
||||
tier,
|
||||
qualityScore,
|
||||
downloads,
|
||||
stars,
|
||||
category,
|
||||
isOfficial,
|
||||
updatedAt,
|
||||
variant = 'default',
|
||||
href,
|
||||
action,
|
||||
icon,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const displayTitle = displayName || name;
|
||||
const isClickable = !!href;
|
||||
|
||||
// Format downloads
|
||||
const formattedDownloads = useMemo(() => {
|
||||
if (downloads === undefined) return null;
|
||||
return formatDownloads(downloads);
|
||||
}, [downloads]);
|
||||
|
||||
// Format update time
|
||||
const formattedTime = useMemo(() => {
|
||||
if (!updatedAt) return null;
|
||||
return formatRelativeTime(updatedAt);
|
||||
}, [updatedAt]);
|
||||
|
||||
const cardClassName = cn(toolCardVariants({ variant, clickable: isClickable ? 'true' : 'false' }), className);
|
||||
|
||||
const cardContent = (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className={toolCardHeaderVariants({ variant })}>
|
||||
{/* Icon */}
|
||||
{icon && (
|
||||
<div className={toolCardIconVariants({ variant })}>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
<h3 className={toolCardTitleVariants({ variant })}>
|
||||
{displayTitle}
|
||||
</h3>
|
||||
{version && (
|
||||
<span className={toolCardVersionVariants({})}>
|
||||
v{version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{isOfficial && (
|
||||
<span className={toolCardOfficialBadgeVariants({})}>
|
||||
<Icon icon="badgeCheck" size="xs" />
|
||||
official
|
||||
</span>
|
||||
)}
|
||||
{tier && (
|
||||
<span className={toolCardTierBadgeVariants({ tier })}>
|
||||
{tier}
|
||||
</span>
|
||||
)}
|
||||
{category && (
|
||||
<span className="font-mono text-[10px] text-foreground-muted">
|
||||
{category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality score (compact display) */}
|
||||
{qualityScore !== undefined && variant !== 'compact' && (
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<div className="font-mono text-lg font-semibold text-primary">
|
||||
{Math.round(qualityScore)}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-foreground-muted uppercase">
|
||||
score
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className={toolCardDescriptionVariants({ variant })}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta info */}
|
||||
<div className={toolCardMetaVariants({ variant })}>
|
||||
{formattedDownloads && (
|
||||
<div className={toolCardMetaItemVariants({})}>
|
||||
<Icon icon="download" size="xs" />
|
||||
{formattedDownloads}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stars !== undefined && (
|
||||
<div className={toolCardMetaItemVariants({})}>
|
||||
<Icon icon="star" size="xs" />
|
||||
{stars}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{author && (
|
||||
<div className={toolCardMetaItemVariants({})}>
|
||||
<Icon icon="user" size="xs" />
|
||||
{author}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formattedTime && (
|
||||
<div className={toolCardMetaItemVariants({})}>
|
||||
<Icon icon="clock" size="xs" />
|
||||
{formattedTime}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quality score for compact variant */}
|
||||
{qualityScore !== undefined && variant === 'compact' && (
|
||||
<div className={cn(toolCardMetaItemVariants({}), 'ml-auto')}>
|
||||
<span className="text-primary font-semibold">
|
||||
{Math.round(qualityScore)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action slot */}
|
||||
{action && (
|
||||
<div className={toolCardActionVariants({ variant })}>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isClickable) {
|
||||
return (
|
||||
<a
|
||||
ref={ref as React.Ref<HTMLAnchorElement>}
|
||||
href={href}
|
||||
className={cardClassName}
|
||||
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
|
||||
>
|
||||
{cardContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cardClassName}
|
||||
{...props}
|
||||
>
|
||||
{cardContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ToolCard.displayName = 'ToolCard';
|
||||
102
packages/ui/src/ToolCard/types.ts
Normal file
102
packages/ui/src/ToolCard/types.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Tool tier types
|
||||
*/
|
||||
export type ToolTier = 'minimal' | 'rich';
|
||||
|
||||
/**
|
||||
* ToolCard variant types
|
||||
*/
|
||||
export type ToolCardVariant = 'default' | 'compact' | 'featured';
|
||||
|
||||
/**
|
||||
* ToolCard component props
|
||||
*/
|
||||
export interface ToolCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Package name
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Package display name (optional, defaults to name)
|
||||
*/
|
||||
displayName?: string;
|
||||
|
||||
/**
|
||||
* Package version
|
||||
*/
|
||||
version?: string;
|
||||
|
||||
/**
|
||||
* Short description
|
||||
*/
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* Package author/maintainer
|
||||
*/
|
||||
author?: string;
|
||||
|
||||
/**
|
||||
* Tool tier (minimal or rich)
|
||||
*/
|
||||
tier?: ToolTier;
|
||||
|
||||
/**
|
||||
* Quality score (0-100)
|
||||
*/
|
||||
qualityScore?: number;
|
||||
|
||||
/**
|
||||
* Monthly downloads count
|
||||
*/
|
||||
downloads?: number;
|
||||
|
||||
/**
|
||||
* GitHub stars count
|
||||
*/
|
||||
stars?: number;
|
||||
|
||||
/**
|
||||
* Category/tags
|
||||
*/
|
||||
category?: string;
|
||||
|
||||
/**
|
||||
* Whether the tool is official/verified
|
||||
*/
|
||||
isOfficial?: boolean;
|
||||
|
||||
/**
|
||||
* Last updated date
|
||||
*/
|
||||
updatedAt?: Date | string;
|
||||
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: ToolCardVariant;
|
||||
|
||||
/**
|
||||
* Link href for the card
|
||||
*/
|
||||
href?: string;
|
||||
|
||||
/**
|
||||
* Custom action slot (e.g., install button)
|
||||
*/
|
||||
action?: ReactNode;
|
||||
|
||||
/**
|
||||
* Icon/logo for the tool
|
||||
*/
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ToolCard ref type
|
||||
*/
|
||||
export type ToolCardRef = HTMLDivElement;
|
||||
244
packages/ui/src/ToolCard/variants.ts
Normal file
244
packages/ui/src/ToolCard/variants.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { createVariants } from '../system/variants';
|
||||
|
||||
/**
|
||||
* ToolCard container variant definitions
|
||||
*/
|
||||
export const toolCardVariants = createVariants({
|
||||
base: [
|
||||
'group',
|
||||
'block w-full',
|
||||
'border border-border',
|
||||
'rounded-none',
|
||||
'bg-surface',
|
||||
'transition-all duration-150',
|
||||
'hover:border-primary/50',
|
||||
'hover:shadow-md',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'p-4',
|
||||
compact: 'p-3',
|
||||
featured: 'p-6 border-2',
|
||||
},
|
||||
clickable: {
|
||||
'true': 'cursor-pointer',
|
||||
'false': '',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
clickable: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* ToolCard header variant definitions
|
||||
*/
|
||||
export const toolCardHeaderVariants = createVariants({
|
||||
base: [
|
||||
'flex items-start gap-3',
|
||||
'mb-3',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
compact: 'mb-2',
|
||||
featured: 'mb-4',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* ToolCard icon variant definitions
|
||||
*/
|
||||
export const toolCardIconVariants = createVariants({
|
||||
base: [
|
||||
'flex-shrink-0',
|
||||
'flex items-center justify-center',
|
||||
'bg-accent/10 border border-border',
|
||||
'rounded-none',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'w-10 h-10',
|
||||
compact: 'w-8 h-8',
|
||||
featured: 'w-12 h-12',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* ToolCard title variant definitions
|
||||
*/
|
||||
export const toolCardTitleVariants = createVariants({
|
||||
base: [
|
||||
'font-mono font-semibold',
|
||||
'text-foreground',
|
||||
'lowercase',
|
||||
'group-hover:text-primary',
|
||||
'transition-colors duration-150',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'text-sm',
|
||||
compact: 'text-sm',
|
||||
featured: 'text-base',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* ToolCard version variant definitions
|
||||
*/
|
||||
export const toolCardVersionVariants = createVariants({
|
||||
base: [
|
||||
'font-mono text-xs',
|
||||
'text-foreground-muted',
|
||||
'ml-2',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* ToolCard description variant definitions
|
||||
*/
|
||||
export const toolCardDescriptionVariants = createVariants({
|
||||
base: [
|
||||
'font-mono',
|
||||
'text-foreground-muted',
|
||||
'line-clamp-2',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'text-sm mb-3',
|
||||
compact: 'text-xs mb-2',
|
||||
featured: 'text-sm mb-4',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* ToolCard meta variant definitions
|
||||
*/
|
||||
export const toolCardMetaVariants = createVariants({
|
||||
base: [
|
||||
'flex items-center gap-4',
|
||||
'font-mono text-xs',
|
||||
'text-foreground-muted',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
compact: 'gap-3',
|
||||
featured: 'gap-4',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* ToolCard meta item variant definitions
|
||||
*/
|
||||
export const toolCardMetaItemVariants = createVariants({
|
||||
base: [
|
||||
'flex items-center gap-1',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* ToolCard tier badge variant definitions
|
||||
*/
|
||||
export const toolCardTierBadgeVariants = createVariants({
|
||||
base: [
|
||||
'inline-flex items-center',
|
||||
'px-1.5 py-0.5',
|
||||
'font-mono text-[10px] font-medium',
|
||||
'uppercase tracking-wider',
|
||||
'border rounded-none',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
tier: {
|
||||
minimal: 'border-border text-foreground-muted bg-accent/5',
|
||||
rich: 'border-primary/50 text-primary bg-primary/5',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
tier: 'minimal',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* ToolCard official badge variant definitions
|
||||
*/
|
||||
export const toolCardOfficialBadgeVariants = createVariants({
|
||||
base: [
|
||||
'inline-flex items-center gap-1',
|
||||
'px-1.5 py-0.5',
|
||||
'font-mono text-[10px] font-medium',
|
||||
'uppercase tracking-wider',
|
||||
'border border-success/50 text-success bg-success/5',
|
||||
'rounded-none',
|
||||
].join(' '),
|
||||
|
||||
variants: {},
|
||||
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* ToolCard action variant definitions
|
||||
*/
|
||||
export const toolCardActionVariants = createVariants({
|
||||
base: [
|
||||
'mt-3',
|
||||
'pt-3',
|
||||
'border-t border-border',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
compact: 'mt-2 pt-2',
|
||||
featured: 'mt-4 pt-4',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
266
packages/ui/src/Tooltip/Tooltip.tsx
Normal file
266
packages/ui/src/Tooltip/Tooltip.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@tpmjs/utils/cn';
|
||||
import {
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { TooltipPlacement, TooltipProps } from './types';
|
||||
import { tooltipArrowVariants, tooltipContentVariants } from './variants';
|
||||
|
||||
/**
|
||||
* Calculate position based on trigger and placement
|
||||
*/
|
||||
function calculatePosition(
|
||||
triggerRect: DOMRect,
|
||||
contentRect: DOMRect,
|
||||
placement: TooltipPlacement,
|
||||
offset: number
|
||||
): { top: number; left: number } {
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
top = triggerRect.top + scrollY - contentRect.height - offset;
|
||||
left = triggerRect.left + scrollX + (triggerRect.width - contentRect.width) / 2;
|
||||
break;
|
||||
case 'top-start':
|
||||
top = triggerRect.top + scrollY - contentRect.height - offset;
|
||||
left = triggerRect.left + scrollX;
|
||||
break;
|
||||
case 'top-end':
|
||||
top = triggerRect.top + scrollY - contentRect.height - offset;
|
||||
left = triggerRect.right + scrollX - contentRect.width;
|
||||
break;
|
||||
case 'bottom':
|
||||
top = triggerRect.bottom + scrollY + offset;
|
||||
left = triggerRect.left + scrollX + (triggerRect.width - contentRect.width) / 2;
|
||||
break;
|
||||
case 'bottom-start':
|
||||
top = triggerRect.bottom + scrollY + offset;
|
||||
left = triggerRect.left + scrollX;
|
||||
break;
|
||||
case 'bottom-end':
|
||||
top = triggerRect.bottom + scrollY + offset;
|
||||
left = triggerRect.right + scrollX - contentRect.width;
|
||||
break;
|
||||
case 'left':
|
||||
top = triggerRect.top + scrollY + (triggerRect.height - contentRect.height) / 2;
|
||||
left = triggerRect.left + scrollX - contentRect.width - offset;
|
||||
break;
|
||||
case 'left-start':
|
||||
top = triggerRect.top + scrollY;
|
||||
left = triggerRect.left + scrollX - contentRect.width - offset;
|
||||
break;
|
||||
case 'left-end':
|
||||
top = triggerRect.bottom + scrollY - contentRect.height;
|
||||
left = triggerRect.left + scrollX - contentRect.width - offset;
|
||||
break;
|
||||
case 'right':
|
||||
top = triggerRect.top + scrollY + (triggerRect.height - contentRect.height) / 2;
|
||||
left = triggerRect.right + scrollX + offset;
|
||||
break;
|
||||
case 'right-start':
|
||||
top = triggerRect.top + scrollY;
|
||||
left = triggerRect.right + scrollX + offset;
|
||||
break;
|
||||
case 'right-end':
|
||||
top = triggerRect.bottom + scrollY - contentRect.height;
|
||||
left = triggerRect.right + scrollX + offset;
|
||||
break;
|
||||
}
|
||||
|
||||
return { top, left };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip component
|
||||
*
|
||||
* A lightweight floating label that appears on hover/focus.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Tooltip } from '@tpmjs/ui/Tooltip/Tooltip';
|
||||
* import { Button } from '@tpmjs/ui/Button/Button';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return (
|
||||
* <Tooltip content="This is a helpful tip">
|
||||
* <Button>Hover me</Button>
|
||||
* </Tooltip>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
content,
|
||||
placement = 'top',
|
||||
offset = 6,
|
||||
showDelay = 200,
|
||||
hideDelay = 0,
|
||||
hasArrow = true,
|
||||
disabled = false,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const isOpen = isControlled ? controlledOpen : internalOpen;
|
||||
|
||||
const triggerRef = useRef<HTMLElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const showTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
const setOpen = useCallback(
|
||||
(value: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(value);
|
||||
}
|
||||
onOpenChange?.(value);
|
||||
},
|
||||
[isControlled, onOpenChange]
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
if (disabled) return;
|
||||
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (showDelay > 0) {
|
||||
showTimeoutRef.current = setTimeout(() => {
|
||||
setOpen(true);
|
||||
}, showDelay);
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [disabled, showDelay, setOpen]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (showTimeoutRef.current) {
|
||||
clearTimeout(showTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (hideDelay > 0) {
|
||||
hideTimeoutRef.current = setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, hideDelay);
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [hideDelay, setOpen]);
|
||||
|
||||
// Update position when open
|
||||
useEffect(() => {
|
||||
if (!isOpen || !triggerRef.current || !contentRef.current) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
const triggerRect = triggerRef.current!.getBoundingClientRect();
|
||||
const contentRect = contentRef.current!.getBoundingClientRect();
|
||||
const newPosition = calculatePosition(triggerRect, contentRect, placement, offset);
|
||||
setPosition(newPosition);
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
// Update on scroll/resize
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, [isOpen, placement, offset]);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
|
||||
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Clone trigger element with event handlers
|
||||
const triggerElement = isValidElement(children)
|
||||
? cloneElement(children as React.ReactElement<any>, {
|
||||
ref: triggerRef,
|
||||
onMouseEnter: (e: React.MouseEvent) => {
|
||||
(children as React.ReactElement<any>).props.onMouseEnter?.(e);
|
||||
handleOpen();
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent) => {
|
||||
(children as React.ReactElement<any>).props.onMouseLeave?.(e);
|
||||
handleClose();
|
||||
},
|
||||
onFocus: (e: React.FocusEvent) => {
|
||||
(children as React.ReactElement<any>).props.onFocus?.(e);
|
||||
handleOpen();
|
||||
},
|
||||
onBlur: (e: React.FocusEvent) => {
|
||||
(children as React.ReactElement<any>).props.onBlur?.(e);
|
||||
handleClose();
|
||||
},
|
||||
})
|
||||
: children;
|
||||
|
||||
// Only render portal in browser
|
||||
const canRenderPortal = typeof window !== 'undefined';
|
||||
|
||||
return (
|
||||
<>
|
||||
{triggerElement}
|
||||
{canRenderPortal &&
|
||||
isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={(node) => {
|
||||
(contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
if (typeof ref === 'function') {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
ref.current = node;
|
||||
}
|
||||
}}
|
||||
role="tooltip"
|
||||
className={cn(tooltipContentVariants({ state: 'entered' }), className)}
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{hasArrow && (
|
||||
<div className={tooltipArrowVariants({ placement })} />
|
||||
)}
|
||||
{content}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Tooltip.displayName = 'Tooltip';
|
||||
84
packages/ui/src/Tooltip/types.ts
Normal file
84
packages/ui/src/Tooltip/types.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Tooltip placement types
|
||||
*/
|
||||
export type TooltipPlacement =
|
||||
| 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'left'
|
||||
| 'left-start'
|
||||
| 'left-end'
|
||||
| 'right'
|
||||
| 'right-start'
|
||||
| 'right-end';
|
||||
|
||||
/**
|
||||
* Tooltip component props
|
||||
*/
|
||||
export interface TooltipProps extends Omit<HTMLAttributes<HTMLDivElement>, 'content'> {
|
||||
/**
|
||||
* The trigger element (must accept ref)
|
||||
*/
|
||||
children: ReactNode;
|
||||
|
||||
/**
|
||||
* The tooltip content (text or ReactNode)
|
||||
*/
|
||||
content: ReactNode;
|
||||
|
||||
/**
|
||||
* Placement of the tooltip relative to trigger
|
||||
* @default 'top'
|
||||
*/
|
||||
placement?: TooltipPlacement;
|
||||
|
||||
/**
|
||||
* Offset from the trigger element in pixels
|
||||
* @default 6
|
||||
*/
|
||||
offset?: number;
|
||||
|
||||
/**
|
||||
* Delay before showing in milliseconds
|
||||
* @default 200
|
||||
*/
|
||||
showDelay?: number;
|
||||
|
||||
/**
|
||||
* Delay before hiding in milliseconds
|
||||
* @default 0
|
||||
*/
|
||||
hideDelay?: number;
|
||||
|
||||
/**
|
||||
* Whether the tooltip has an arrow
|
||||
* @default true
|
||||
*/
|
||||
hasArrow?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the tooltip is disabled
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the tooltip is open (controlled mode)
|
||||
*/
|
||||
open?: boolean;
|
||||
|
||||
/**
|
||||
* Callback when open state changes
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip ref type
|
||||
*/
|
||||
export type TooltipRef = HTMLDivElement;
|
||||
68
packages/ui/src/Tooltip/variants.ts
Normal file
68
packages/ui/src/Tooltip/variants.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { createVariants } from '../system/variants';
|
||||
|
||||
/**
|
||||
* Tooltip content variant definitions
|
||||
*/
|
||||
export const tooltipContentVariants = createVariants({
|
||||
base: [
|
||||
// Layout
|
||||
'absolute',
|
||||
'z-[var(--z-tooltip)]',
|
||||
'px-2 py-1',
|
||||
'max-w-xs',
|
||||
// Styling - Sharp corners, inverted colors for contrast
|
||||
'bg-foreground text-background',
|
||||
'rounded-none',
|
||||
// Typography
|
||||
'font-mono text-xs',
|
||||
// Animation
|
||||
'transition-opacity duration-150',
|
||||
// Pointer events
|
||||
'pointer-events-none',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
state: {
|
||||
entering: 'opacity-0',
|
||||
entered: 'opacity-100',
|
||||
exiting: 'opacity-0',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
state: 'entered',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Tooltip arrow variant definitions
|
||||
*/
|
||||
export const tooltipArrowVariants = createVariants({
|
||||
base: [
|
||||
'absolute',
|
||||
'w-2 h-2',
|
||||
'bg-foreground',
|
||||
'rotate-45',
|
||||
].join(' '),
|
||||
|
||||
variants: {
|
||||
placement: {
|
||||
top: 'bottom-[-4px] left-1/2 -translate-x-1/2',
|
||||
'top-start': 'bottom-[-4px] left-3',
|
||||
'top-end': 'bottom-[-4px] right-3',
|
||||
bottom: 'top-[-4px] left-1/2 -translate-x-1/2',
|
||||
'bottom-start': 'top-[-4px] left-3',
|
||||
'bottom-end': 'top-[-4px] right-3',
|
||||
left: 'right-[-4px] top-1/2 -translate-y-1/2',
|
||||
'left-start': 'right-[-4px] top-2',
|
||||
'left-end': 'right-[-4px] bottom-2',
|
||||
right: 'left-[-4px] top-1/2 -translate-y-1/2',
|
||||
'right-start': 'left-[-4px] top-2',
|
||||
'right-end': 'left-[-4px] bottom-2',
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
placement: 'top',
|
||||
},
|
||||
});
|
||||
|
|
@ -10,18 +10,18 @@
|
|||
export const formInputBase = [
|
||||
// Layout
|
||||
'flex w-full',
|
||||
// Typography
|
||||
'font-sans text-base',
|
||||
// Borders and radius
|
||||
'rounded-md border border-border',
|
||||
// Typography - Monospace for inputs
|
||||
'font-mono text-base',
|
||||
// Borders and radius - SHARP CORNERS
|
||||
'rounded-none border border-border',
|
||||
// Colors - Pure white background to stand out
|
||||
'bg-surface text-foreground',
|
||||
// Placeholder
|
||||
'placeholder:text-foreground-tertiary',
|
||||
// Transitions
|
||||
'transition-base',
|
||||
// Focus state
|
||||
'focus-ring',
|
||||
// Transitions - Fast, subtle
|
||||
'transition-colors duration-150',
|
||||
// Focus state - Copper accent
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
||||
// Hover state
|
||||
'hover:border-border-strong',
|
||||
// Disabled state
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue