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:
Ajax Davis 2026-01-16 05:51:12 +10:00
parent 46b212e65a
commit 066e599293
77 changed files with 12045 additions and 195 deletions

View file

@ -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": [

View 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';

View 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;
}

View 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: {},
});

View file

@ -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: {

View 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';

View 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;

View 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: {},
});

View file

@ -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(' '),

View file

@ -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

View file

@ -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',

View file

@ -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)

View 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';

View 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;

View 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: {},
});

View 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';

View 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;

View 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: {},
});

View file

@ -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;

View file

@ -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

View 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';

View 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;

View 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' },
],
});

View 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';

View 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;

View 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: {},
});

View 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';

View 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;

View 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',
},
});

View 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';

View 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>;
}

View 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: {},
});

View file

@ -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: {

View 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';

View 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;

View 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',
},
});

View 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';

View 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;

View 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',
},
});

View file

@ -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: {

View file

@ -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

View 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;
}

View 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;

View 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: {},
});

View 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';

View 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;

View 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',
},
});

View 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';

View 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;

View 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',
},
});

View file

@ -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