website refactor
This commit is contained in:
@@ -1,49 +1,40 @@
|
||||
|
||||
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Icon } from './Icon';
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface AccordionProps {
|
||||
export interface AccordionProps {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export function Accordion({ title, icon, children, isOpen, onToggle }: AccordionProps) {
|
||||
export const Accordion = ({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = false
|
||||
}: AccordionProps) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<Box border borderColor="border-charcoal-outline" rounded="lg" overflow="hidden" bg="bg-iron-gray/30">
|
||||
<Box
|
||||
as="button"
|
||||
onClick={onToggle}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
px={3}
|
||||
py={2}
|
||||
fullWidth
|
||||
hoverBg="iron-gray/50"
|
||||
clickable
|
||||
<Surface variant="muted" rounded="lg" style={{ border: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{icon}
|
||||
<Text size="xs" weight="semibold" color="text-gray-400" uppercase letterSpacing="wide">
|
||||
{title}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Icon icon={isOpen ? ChevronDown : ChevronUp} size={4} color="text-gray-400" />
|
||||
</Box>
|
||||
<Text weight="bold" size="sm" variant="high">
|
||||
{title}
|
||||
</Text>
|
||||
<Icon icon={isOpen ? ChevronUp : ChevronDown} size={4} intent="low" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<Box p={3} borderTop borderColor="border-charcoal-outline">
|
||||
<Box padding={4} borderTop>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,67 +1,44 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Link } from './Link';
|
||||
|
||||
interface ActivityItemProps {
|
||||
title?: string;
|
||||
export interface ActivityItemProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
timeAgo?: string;
|
||||
color?: string;
|
||||
headline?: string;
|
||||
body?: string;
|
||||
formattedTime?: string;
|
||||
ctaHref?: string;
|
||||
ctaLabel?: string;
|
||||
timestamp: string;
|
||||
icon?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ActivityItem({
|
||||
export const ActivityItem = ({
|
||||
title,
|
||||
description,
|
||||
timeAgo,
|
||||
color = 'bg-primary-blue',
|
||||
headline,
|
||||
body,
|
||||
formattedTime,
|
||||
ctaHref,
|
||||
ctaLabel
|
||||
}: ActivityItemProps) {
|
||||
timestamp,
|
||||
icon,
|
||||
children
|
||||
}: ActivityItemProps) => {
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
display="flex"
|
||||
alignItems="start"
|
||||
gap={3}
|
||||
p={4}
|
||||
>
|
||||
<Box
|
||||
w="2"
|
||||
h="2"
|
||||
mt={1.5}
|
||||
rounded="full"
|
||||
bg={color}
|
||||
flexShrink={0}
|
||||
/>
|
||||
<Box flex={1} minWidth={0}>
|
||||
<Text color="text-white" weight="medium" block>
|
||||
{title || headline}
|
||||
</Text>
|
||||
<Text size="sm" color="text-gray-400" block mt={0.5}>
|
||||
{description || body}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block mt={2}>
|
||||
{timeAgo || formattedTime}
|
||||
</Text>
|
||||
{ctaHref && ctaLabel && (
|
||||
<Box mt={3}>
|
||||
<Link href={ctaHref} size="xs" variant="primary">
|
||||
{ctaLabel}
|
||||
</Link>
|
||||
<Surface variant="muted" rounded="lg" padding={4}>
|
||||
<Box display="flex" alignItems="start" gap={4}>
|
||||
{icon && (
|
||||
<Box padding={2} rounded="lg" bg="var(--ui-color-bg-surface-muted)">
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Box flex={1}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" marginBottom={1}>
|
||||
<Text weight="bold" variant="high">{title}</Text>
|
||||
<Text size="xs" variant="low">{timestamp}</Text>
|
||||
</Box>
|
||||
{description && (
|
||||
<Text size="sm" variant="low" marginBottom={children ? 4 : 0}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,44 +1,62 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Image } from './Image';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Box } from './primitives/Box';
|
||||
import { User } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface AvatarProps {
|
||||
src?: string | null;
|
||||
alt: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
export interface AvatarProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
export function Avatar({ src, alt, size = 40, className = '' }: AvatarProps) {
|
||||
export const Avatar = ({
|
||||
src,
|
||||
alt,
|
||||
size = 'md',
|
||||
fallback
|
||||
}: AvatarProps) => {
|
||||
const sizeMap = {
|
||||
sm: '2rem',
|
||||
md: '3rem',
|
||||
lg: '4rem',
|
||||
xl: '6rem'
|
||||
};
|
||||
|
||||
const iconSizeMap = {
|
||||
sm: 3,
|
||||
md: 5,
|
||||
lg: 8,
|
||||
xl: 12
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="full"
|
||||
border
|
||||
borderColor="border-charcoal-outline/50"
|
||||
className={className}
|
||||
w={`${size}px`}
|
||||
h={`${size}px`}
|
||||
flexShrink={0}
|
||||
overflow="hidden"
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="full"
|
||||
style={{
|
||||
width: sizeMap[size],
|
||||
height: sizeMap[size],
|
||||
overflow: 'hidden',
|
||||
border: '2px solid var(--ui-color-border-default)'
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fullWidth
|
||||
fullHeight
|
||||
className="object-cover"
|
||||
fallbackSrc="/default-avatar.png"
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Box fullWidth fullHeight bg="bg-charcoal-outline" display="flex" center>
|
||||
<span className="text-gray-400 font-bold" style={{ fontSize: size * 0.4 }}>
|
||||
{alt.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<Box center fullWidth fullHeight bg="var(--ui-color-bg-base)">
|
||||
{fallback ? (
|
||||
<span className="text-sm font-bold text-[var(--ui-color-text-med)]">{fallback}</span>
|
||||
) : (
|
||||
<Icon icon={User} size={iconSizeMap[size]} intent="low" />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,43 +1,57 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box, BoxProps } from './primitives/Box';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Stack } from './primitives/Stack';
|
||||
|
||||
interface BadgeProps {
|
||||
export interface BadgeProps {
|
||||
children: ReactNode;
|
||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
variant?: 'primary' | 'secondary' | 'success' | 'warning' | 'critical' | 'info' | 'outline' | 'default' | 'danger';
|
||||
size?: 'sm' | 'md';
|
||||
style?: React.CSSProperties;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export function Badge({ children, variant = 'default', size = 'sm', icon }: BadgeProps) {
|
||||
const baseClasses = 'flex items-center gap-1.5 border font-bold uppercase tracking-widest';
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'px-1.5 py-0.5 text-[9px]',
|
||||
sm: 'px-2 py-0.5 text-[10px]',
|
||||
md: 'px-3 py-1 text-xs'
|
||||
export const Badge = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
style,
|
||||
icon
|
||||
}: BadgeProps) => {
|
||||
const variantClasses = {
|
||||
primary: 'bg-[var(--ui-color-intent-primary)] text-white',
|
||||
secondary: 'bg-[var(--ui-color-bg-surface)] text-[var(--ui-color-text-med)] border border-[var(--ui-color-border-default)]',
|
||||
success: 'bg-[var(--ui-color-intent-success)] text-[var(--ui-color-bg-base)]',
|
||||
warning: 'bg-[var(--ui-color-intent-warning)] text-[var(--ui-color-bg-base)]',
|
||||
critical: 'bg-[var(--ui-color-intent-critical)] text-white',
|
||||
danger: 'bg-[var(--ui-color-intent-critical)] text-white',
|
||||
info: 'bg-[var(--ui-color-intent-telemetry)] text-[var(--ui-color-bg-base)]',
|
||||
outline: 'bg-transparent text-[var(--ui-color-text-med)] border border-[var(--ui-color-border-default)]',
|
||||
default: 'bg-[var(--ui-color-bg-surface-muted)] text-[var(--ui-color-text-med)]',
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-gray-500/10 border-gray-500/30 text-gray-400',
|
||||
primary: 'bg-primary-accent/10 border-primary-accent/30 text-primary-accent',
|
||||
success: 'bg-success-green/10 border-success-green/30 text-success-green',
|
||||
warning: 'bg-warning-amber/10 border-warning-amber/30 text-warning-amber',
|
||||
danger: 'bg-critical-red/10 border-critical-red/30 text-critical-red',
|
||||
info: 'bg-telemetry-aqua/10 border-telemetry-aqua/30 text-telemetry-aqua'
|
||||
const sizeClasses = {
|
||||
sm: 'px-1.5 py-0.5 text-[10px]',
|
||||
md: 'px-2 py-0.5 text-xs',
|
||||
};
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
sizeClasses[size],
|
||||
'inline-flex items-center justify-center font-bold uppercase tracking-wider rounded-none',
|
||||
variantClasses[variant],
|
||||
].filter(Boolean).join(' ');
|
||||
sizeClasses[size],
|
||||
].join(' ');
|
||||
|
||||
const content = icon ? (
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={icon} size={3} />
|
||||
{children}
|
||||
</Stack>
|
||||
) : children;
|
||||
|
||||
return (
|
||||
<Box className={classes}>
|
||||
{icon && <Icon icon={icon} size={3} />}
|
||||
{children}
|
||||
<Box as="span" className={classes} style={style}>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,63 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface Tab {
|
||||
export interface TabOption {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface BorderTabsProps {
|
||||
tabs: Tab[];
|
||||
activeTab: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
className?: string;
|
||||
export interface BorderTabsProps {
|
||||
tabs: TabOption[];
|
||||
activeTabId: string;
|
||||
onTabChange: (id: string) => void;
|
||||
}
|
||||
|
||||
export function BorderTabs({ tabs, activeTab, onTabChange, className = '' }: BorderTabsProps) {
|
||||
export const BorderTabs = ({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onTabChange
|
||||
}: BorderTabsProps) => {
|
||||
return (
|
||||
<Box borderBottom borderColor="border-border-gray/50" className={className}>
|
||||
<Stack direction="row" gap={8}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<Surface
|
||||
key={tab.id}
|
||||
as="button"
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
variant="ghost"
|
||||
px={1}
|
||||
py={4}
|
||||
position="relative"
|
||||
borderColor={isActive ? 'border-primary-blue' : ''}
|
||||
borderBottom={isActive}
|
||||
borderWidth={isActive ? '2px' : '0'}
|
||||
mb="-1px"
|
||||
transition="all 0.2s"
|
||||
group
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{tab.icon && (
|
||||
<Box color={isActive ? 'text-primary-blue' : 'text-gray-400'} groupHoverTextColor={!isActive ? 'white' : undefined}>
|
||||
{tab.icon}
|
||||
</Box>
|
||||
)}
|
||||
<Text
|
||||
size="sm"
|
||||
weight="medium"
|
||||
color={isActive ? 'text-primary-blue' : 'text-gray-400'}
|
||||
groupHoverTextColor={!isActive ? 'white' : undefined}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
<Box display="flex" borderBottom>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`px-6 py-4 text-sm font-bold uppercase tracking-widest transition-all relative ${
|
||||
isActive
|
||||
? 'text-[var(--ui-color-intent-primary)]'
|
||||
: 'text-[var(--ui-color-text-low)] hover:text-[var(--ui-color-text-high)]'
|
||||
}`}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Box>
|
||||
{isActive && (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="2px"
|
||||
bg="var(--ui-color-intent-primary)"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Breadcrumbs, BreadcrumbItem } from './Breadcrumbs';
|
||||
|
||||
interface BreadcrumbBarProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
export interface BreadcrumbBarProps {
|
||||
items: BreadcrumbItem[];
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* BreadcrumbBar is a container for breadcrumbs, typically placed at the top of the ContentShell.
|
||||
*/
|
||||
export function BreadcrumbBar({ children, className = '' }: BreadcrumbBarProps) {
|
||||
export const BreadcrumbBar = ({
|
||||
items,
|
||||
actions
|
||||
}: BreadcrumbBarProps) => {
|
||||
return (
|
||||
<div className={`mb-6 flex items-center space-x-2 text-sm ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
paddingY={4}
|
||||
borderBottom
|
||||
>
|
||||
<Breadcrumbs items={items} />
|
||||
{actions && (
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,50 +1,39 @@
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
import { Link } from './Link';
|
||||
|
||||
export type BreadcrumbItem = {
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
export interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export function Breadcrumbs({ items }: BreadcrumbsProps) {
|
||||
if (!items || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastIndex = items.length - 1;
|
||||
|
||||
export const Breadcrumbs = ({ items }: BreadcrumbsProps) => {
|
||||
return (
|
||||
<Box as="nav" aria-label="Breadcrumb" mb={4}>
|
||||
<Stack direction="row" align="center" gap={2} wrap>
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === lastIndex;
|
||||
const content = item.href && !isLast ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
variant="ghost"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<Text color={isLast ? 'text-white' : 'text-gray-400'}>{item.label}</Text>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack key={`${item.label}-${index}`} direction="row" align="center" gap={2}>
|
||||
{index > 0 && (
|
||||
<Text color="text-gray-600">/</Text>
|
||||
)}
|
||||
{content}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && <Icon icon={ChevronRight} size={3} intent="low" />}
|
||||
{isLast || !item.href ? (
|
||||
<Text size="sm" variant={isLast ? 'high' : 'low'} weight={isLast ? 'bold' : 'normal'}>
|
||||
{item.label}
|
||||
</Text>
|
||||
) : (
|
||||
<Link href={item.href} variant="secondary">
|
||||
<Text size="sm">{item.label}</Text>
|
||||
</Link>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { ReactNode, MouseEventHandler, ButtonHTMLAttributes, forwardRef } from 'react';
|
||||
import React, { ReactNode, MouseEventHandler, forwardRef } from 'react';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Box, BoxProps } from './primitives/Box';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ResponsiveValue } from './primitives/Box';
|
||||
|
||||
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'as' | 'onMouseEnter' | 'onMouseLeave' | 'onSubmit' | 'role' | 'translate' | 'onScroll' | 'draggable' | 'onChange' | 'onMouseDown' | 'onMouseUp' | 'onMouseMove' | 'value' | 'onBlur' | 'onKeyDown'>, Omit<BoxProps<'button'>, 'as' | 'onClick' | 'onSubmit'> {
|
||||
export interface ButtonProps {
|
||||
children: ReactNode;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
className?: string;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'race-final' | 'discord';
|
||||
onClick?: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'success' | 'discord' | 'race-final';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
@@ -19,14 +18,25 @@ interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'as'
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
width?: string | number | ResponsiveValue<string | number>;
|
||||
height?: string | number | ResponsiveValue<string | number>;
|
||||
minWidth?: string | number;
|
||||
px?: number;
|
||||
py?: number;
|
||||
p?: number;
|
||||
rounded?: string;
|
||||
bg?: string;
|
||||
color?: string;
|
||||
fontSize?: string;
|
||||
backgroundColor?: string;
|
||||
h?: string;
|
||||
w?: string;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(({
|
||||
children,
|
||||
onClick,
|
||||
className = '',
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
@@ -38,25 +48,37 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
className,
|
||||
style: styleProp,
|
||||
width,
|
||||
height,
|
||||
minWidth,
|
||||
px,
|
||||
py,
|
||||
p,
|
||||
rounded,
|
||||
bg,
|
||||
color,
|
||||
fontSize,
|
||||
backgroundColor,
|
||||
...props
|
||||
h,
|
||||
w,
|
||||
}, ref) => {
|
||||
const baseClasses = 'inline-flex items-center justify-center rounded-none transition-all duration-150 ease-smooth focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 active:opacity-80 uppercase tracking-widest font-bold';
|
||||
const baseClasses = 'inline-flex items-center justify-center rounded-none transition-all duration-150 ease-in-out focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 active:opacity-80 uppercase tracking-widest font-bold';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary-accent text-white hover:bg-primary-accent/90 focus-visible:outline-primary-accent shadow-[0_0_15px_rgba(25,140,255,0.3)] hover:shadow-[0_0_25px_rgba(25,140,255,0.5)]',
|
||||
secondary: 'bg-panel-gray text-white border border-border-gray hover:bg-border-gray/50 focus-visible:outline-primary-accent',
|
||||
danger: 'bg-critical-red text-white hover:bg-critical-red/90 focus-visible:outline-critical-red',
|
||||
ghost: 'bg-transparent text-gray-400 hover:text-white hover:bg-white/5 focus-visible:outline-gray-400',
|
||||
'race-final': 'bg-success-green text-graphite-black hover:bg-success-green/90 focus-visible:outline-success-green',
|
||||
primary: 'bg-[var(--ui-color-intent-primary)] text-white hover:opacity-90 focus-visible:outline-[var(--ui-color-intent-primary)] shadow-[0_0_15px_rgba(25,140,255,0.3)] hover:shadow-[0_0_25px_rgba(25,140,255,0.5)]',
|
||||
secondary: 'bg-[var(--ui-color-bg-surface)] text-white border border-[var(--ui-color-border-default)] hover:bg-[var(--ui-color-border-default)] focus-visible:outline-[var(--ui-color-intent-primary)]',
|
||||
danger: 'bg-[var(--ui-color-intent-critical)] text-white hover:opacity-90 focus-visible:outline-[var(--ui-color-intent-critical)]',
|
||||
ghost: 'bg-transparent text-[var(--ui-color-text-low)] hover:text-[var(--ui-color-text-high)] hover:bg-white/5 focus-visible:outline-[var(--ui-color-text-low)]',
|
||||
success: 'bg-[var(--ui-color-intent-success)] text-[var(--ui-color-bg-base)] hover:opacity-90 focus-visible:outline-[var(--ui-color-intent-success)]',
|
||||
'race-final': 'bg-[var(--ui-color-intent-success)] text-[var(--ui-color-bg-base)] hover:opacity-90 focus-visible:outline-[var(--ui-color-intent-success)]',
|
||||
discord: 'bg-[#5865F2] text-white hover:bg-[#4752C4] focus-visible:outline-[#5865F2]',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'min-h-[32px] px-3 py-1 text-xs font-medium',
|
||||
md: 'min-h-[40px] px-4 py-2 text-sm font-medium',
|
||||
lg: 'min-h-[48px] px-6 py-3 text-base font-medium'
|
||||
sm: 'min-h-[32px] px-3 py-1 text-xs',
|
||||
md: 'min-h-[40px] px-4 py-2 text-sm',
|
||||
lg: 'min-h-[48px] px-6 py-3 text-base'
|
||||
};
|
||||
|
||||
const disabledClasses = (disabled || isLoading) ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer';
|
||||
@@ -71,8 +93,18 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...(width ? { width: typeof width === 'object' ? undefined : width } : {}),
|
||||
...(height ? { height: typeof height === 'object' ? undefined : height } : {}),
|
||||
...(minWidth ? { minWidth } : {}),
|
||||
...(fontSize ? { fontSize } : {}),
|
||||
...(h ? { height: h } : {}),
|
||||
...(w ? { width: w } : {}),
|
||||
...(styleProp || {})
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Stack direction="row" align="center" gap={2} center={fullWidth}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{isLoading && <Icon icon={Loader2} size={size === 'sm' ? 3 : 4} animate="spin" />}
|
||||
{!isLoading && icon}
|
||||
{children}
|
||||
@@ -81,35 +113,31 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
|
||||
if (as === 'a') {
|
||||
return (
|
||||
<Box
|
||||
as="a"
|
||||
<a
|
||||
ref={ref as React.ForwardedRef<HTMLAnchorElement>}
|
||||
href={href}
|
||||
target={target}
|
||||
rel={rel}
|
||||
className={classes}
|
||||
fontSize={fontSize}
|
||||
backgroundColor={backgroundColor}
|
||||
{...props}
|
||||
onClick={onClick as MouseEventHandler<HTMLAnchorElement>}
|
||||
style={style}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
ref={ref}
|
||||
<button
|
||||
ref={ref as React.ForwardedRef<HTMLButtonElement>}
|
||||
type={type}
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
onClick={onClick as MouseEventHandler<HTMLButtonElement>}
|
||||
disabled={disabled || isLoading}
|
||||
fontSize={fontSize}
|
||||
backgroundColor={backgroundColor}
|
||||
{...props}
|
||||
style={style}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,49 +1,73 @@
|
||||
import React, { ReactNode, MouseEventHandler } from 'react';
|
||||
import { Box, BoxProps } from './primitives/Box';
|
||||
import React, { ReactNode, forwardRef } from 'react';
|
||||
import { Surface, SurfaceProps } from './primitives/Surface';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
export interface CardProps extends Omit<BoxProps<'div'>, 'children' | 'onClick'> {
|
||||
export interface CardProps extends Omit<SurfaceProps<'div'>, 'children' | 'title' | 'variant'> {
|
||||
children: ReactNode;
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
variant?: 'default' | 'outline' | 'ghost' | 'muted' | 'dark' | 'glass';
|
||||
variant?: 'default' | 'dark' | 'muted' | 'glass' | 'outline';
|
||||
title?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
export const Card = forwardRef<HTMLDivElement, CardProps>(({
|
||||
children,
|
||||
className = '',
|
||||
onClick,
|
||||
variant = 'default',
|
||||
title,
|
||||
footer,
|
||||
...props
|
||||
}: CardProps) {
|
||||
const baseClasses = 'rounded-none transition-all duration-150 ease-smooth';
|
||||
}, ref) => {
|
||||
const isOutline = variant === 'outline';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-panel-gray border border-border-gray shadow-card',
|
||||
outline: 'bg-transparent border border-border-gray',
|
||||
ghost: 'bg-transparent border-none',
|
||||
muted: 'bg-panel-gray/40 border border-border-gray',
|
||||
dark: 'bg-graphite-black border border-border-gray',
|
||||
glass: 'bg-graphite-black/60 backdrop-blur-md border border-border-gray'
|
||||
};
|
||||
const style: React.CSSProperties = isOutline ? {
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid var(--ui-color-border-default)',
|
||||
} : {};
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
onClick ? 'cursor-pointer hover:bg-border-gray/30' : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
// Default padding if none provided
|
||||
const hasPadding = props.p !== undefined || props.px !== undefined || props.py !== undefined ||
|
||||
props.pt !== undefined || props.pb !== undefined || props.pl !== undefined || props.pr !== undefined;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
p={hasPadding ? undefined : 4}
|
||||
<Surface
|
||||
ref={ref}
|
||||
variant={isOutline ? 'default' : variant}
|
||||
rounded="lg"
|
||||
shadow="md"
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
{title && (
|
||||
<Box padding={4} borderBottom>
|
||||
{typeof title === 'string' ? (
|
||||
<h3 className="text-lg font-bold text-[var(--ui-color-text-high)]">{title}</h3>
|
||||
) : title}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box padding={4}>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{footer && (
|
||||
<Box padding={4} borderTop bg="rgba(255,255,255,0.02)">
|
||||
{footer}
|
||||
</Box>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
export const CardHeader = ({ title, children }: { title?: string, children?: ReactNode }) => (
|
||||
<Box marginBottom={4}>
|
||||
{title && <h3 className="text-lg font-bold text-[var(--ui-color-text-high)]">{title}</h3>}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const CardContent = ({ children }: { children: ReactNode }) => (
|
||||
<Box>{children}</Box>
|
||||
);
|
||||
|
||||
export const CardFooter = ({ children }: { children: ReactNode }) => (
|
||||
<Box marginTop={4} paddingTop={4} borderTop>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,77 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { CategoryDistributionCard } from './CategoryDistributionCard';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
|
||||
import { CategoryDistributionCard } from '@/ui/CategoryDistributionCard';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Grid } from '@/ui/primitives/Grid';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'beginner', label: 'Beginner', color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30', progressColor: 'bg-green-400' },
|
||||
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30', progressColor: 'bg-primary-blue' },
|
||||
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30', progressColor: 'bg-purple-400' },
|
||||
{ id: 'pro', label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30', progressColor: 'bg-yellow-400' },
|
||||
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400', bgColor: 'bg-orange-400/10', borderColor: 'border-orange-400/30', progressColor: 'bg-orange-400' },
|
||||
{ id: 'sprint', label: 'Sprint', color: 'text-red-400', bgColor: 'bg-red-400/10', borderColor: 'border-red-400/30', progressColor: 'bg-red-400' },
|
||||
];
|
||||
|
||||
interface CategoryDistributionProps {
|
||||
drivers: {
|
||||
category?: string;
|
||||
}[];
|
||||
export interface CategoryData {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
icon: LucideIcon;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
}
|
||||
|
||||
export function CategoryDistribution({ drivers }: CategoryDistributionProps) {
|
||||
const distribution = CATEGORIES.map((category) => ({
|
||||
...category,
|
||||
count: drivers.filter((d) => d.category === category.id).length,
|
||||
percentage: drivers.length > 0
|
||||
? Math.round((drivers.filter((d) => d.category === category.id).length / drivers.length) * 100)
|
||||
: 0,
|
||||
}));
|
||||
export interface CategoryDistributionProps {
|
||||
categories: CategoryData[];
|
||||
}
|
||||
|
||||
export const CategoryDistribution = ({
|
||||
categories
|
||||
}: CategoryDistributionProps) => {
|
||||
const total = categories.reduce((acc, cat) => acc + cat.count, 0);
|
||||
|
||||
return (
|
||||
<Box mb={10}>
|
||||
<Box display="flex" alignItems="center" gap={3} mb={4}>
|
||||
<Box
|
||||
display="flex"
|
||||
h="10"
|
||||
w="10"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="xl"
|
||||
bg="bg-purple-400/10"
|
||||
border
|
||||
borderColor="border-purple-400/20"
|
||||
>
|
||||
<Icon
|
||||
icon={BarChart3}
|
||||
size={5}
|
||||
color="rgb(192, 132, 252)"
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={2}>Category Distribution</Heading>
|
||||
<Text size="xs" color="text-gray-500">Driver population by category</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid cols={2} lgCols={3} gap={4}>
|
||||
{distribution.map((category) => (
|
||||
<CategoryDistributionCard
|
||||
key={category.id}
|
||||
label={category.label}
|
||||
count={category.count}
|
||||
percentage={category.percentage}
|
||||
color={category.color}
|
||||
bgColor={category.bgColor}
|
||||
borderColor={category.borderColor}
|
||||
progressColor={category.progressColor}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
<Box display="grid" gridCols={{ base: 1, md: 2, lg: 3 }} gap={4}>
|
||||
{categories.map((category) => (
|
||||
<CategoryDistributionCard
|
||||
key={category.id}
|
||||
label={category.label}
|
||||
count={category.count}
|
||||
total={total}
|
||||
icon={category.icon}
|
||||
intent={category.intent}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,47 +3,57 @@ import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface CategoryDistributionCardProps {
|
||||
export interface CategoryDistributionCardProps {
|
||||
label: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
total: number;
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
}
|
||||
|
||||
export function CategoryDistributionCard({
|
||||
label,
|
||||
count,
|
||||
percentage,
|
||||
icon,
|
||||
color,
|
||||
bgColor,
|
||||
borderColor,
|
||||
}: CategoryDistributionCardProps) {
|
||||
export const CategoryDistributionCard = ({
|
||||
label,
|
||||
count,
|
||||
total,
|
||||
icon,
|
||||
intent = 'primary'
|
||||
}: CategoryDistributionCardProps) => {
|
||||
const percentage = total > 0 ? (count / total) * 100 : 0;
|
||||
|
||||
const intentColorMap = {
|
||||
primary: 'var(--ui-color-intent-primary)',
|
||||
success: 'var(--ui-color-intent-success)',
|
||||
warning: 'var(--ui-color-intent-warning)',
|
||||
critical: 'var(--ui-color-intent-critical)',
|
||||
telemetry: 'var(--ui-color-intent-telemetry)',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box p={4} rounded="xl" bg={bgColor} border borderColor={borderColor}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={3}>
|
||||
<Text size="2xl" weight="bold" color={color}>{count}</Text>
|
||||
<Box p={2} rounded="lg" bg="bg-white/5">
|
||||
<Icon icon={icon} size={5} color={color} />
|
||||
<Surface variant="muted" rounded="xl" padding={4} style={{ border: '1px solid var(--ui-color-border-default)' }}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" marginBottom={3}>
|
||||
<Text size="2xl" weight="bold" variant="high">{count}</Text>
|
||||
<Box padding={2} rounded="lg" bg="var(--ui-color-bg-surface-muted)">
|
||||
<Icon icon={icon} size={5} intent={intent} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Text size="sm" weight="medium" color="text-white" block mb={1}>
|
||||
|
||||
<Text size="sm" weight="medium" variant="high" block marginBottom={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<Box w="full" h="1.5" bg="bg-white/5" rounded="full" overflow="hidden">
|
||||
<Box
|
||||
h="full"
|
||||
bg={color.replace('text-', 'bg-')}
|
||||
style={{ width: `${percentage}%` }}
|
||||
|
||||
<Box fullWidth height="0.375rem" bg="var(--ui-color-bg-surface-muted)" style={{ borderRadius: '9999px', overflow: 'hidden' }}>
|
||||
<Box
|
||||
fullHeight
|
||||
bg={intentColorMap[intent]}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</Box>
|
||||
<Text size="xs" color="text-gray-500" mt={2}>
|
||||
{percentage.toFixed(1)}% of total
|
||||
|
||||
<Text size="xs" variant="low" marginTop={2}>
|
||||
{Math.round(percentage)}% of total
|
||||
</Text>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,44 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Image } from './Image';
|
||||
import { Tag } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
import { Tag } from 'lucide-react';
|
||||
|
||||
export interface CategoryIconProps {
|
||||
categoryId?: string;
|
||||
src?: string;
|
||||
alt: string;
|
||||
category: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CategoryIcon({
|
||||
categoryId,
|
||||
src,
|
||||
alt,
|
||||
size = 24,
|
||||
className = '',
|
||||
}: CategoryIconProps) {
|
||||
const iconSrc = src || (categoryId ? `/media/categories/${categoryId}/icon` : undefined);
|
||||
|
||||
export const CategoryIcon = ({
|
||||
category,
|
||||
size = 24
|
||||
}: CategoryIconProps) => {
|
||||
// Map categories to icons if needed, for now just use Tag
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
className={className}
|
||||
style={{ width: size, height: size, flexShrink: 0 }}
|
||||
<Box
|
||||
width={size}
|
||||
height={size}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="var(--ui-color-bg-surface-muted)"
|
||||
style={{ borderRadius: 'var(--ui-radius-sm)' }}
|
||||
>
|
||||
{iconSrc ? (
|
||||
<Image
|
||||
src={iconSrc}
|
||||
alt={alt}
|
||||
className="w-full h-full object-contain"
|
||||
fallbackSrc="/default-category-icon.png"
|
||||
/>
|
||||
) : (
|
||||
<Icon icon={Tag} size={size > 20 ? 4 : 3} color="text-gray-500" />
|
||||
)}
|
||||
<Icon icon={Tag} size={size > 20 ? 4 : 3} intent="low" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,33 +1,50 @@
|
||||
import React from 'react';
|
||||
import React, { forwardRef, ChangeEvent } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface CheckboxProps {
|
||||
export interface CheckboxProps {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Checkbox({ label, checked, onChange, disabled }: CheckboxProps) {
|
||||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
error
|
||||
}, ref) => {
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box as="label" display="flex" alignItems="center" gap={2} cursor={disabled ? 'not-allowed' : 'pointer'}>
|
||||
<Box
|
||||
as="input"
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
w="4"
|
||||
h="4"
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="sm"
|
||||
ring="primary-blue"
|
||||
color="text-primary-blue"
|
||||
/>
|
||||
<Text size="sm" color={disabled ? 'text-gray-500' : 'text-white'}>{label}</Text>
|
||||
<Box>
|
||||
<Box as="label" display="flex" alignItems="center" gap={2} style={{ cursor: disabled ? 'not-allowed' : 'pointer' }}>
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 rounded-none border-[var(--ui-color-border-default)] bg-[var(--ui-color-bg-surface)] text-[var(--ui-color-intent-primary)] focus:ring-[var(--ui-color-intent-primary)]"
|
||||
/>
|
||||
<Text size="sm" variant={disabled ? 'low' : 'high'}>
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text size="xs" variant="critical">
|
||||
{error}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
|
||||
@@ -1,53 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
|
||||
|
||||
interface CircularProgressProps {
|
||||
export interface CircularProgressProps {
|
||||
value: number;
|
||||
max: number;
|
||||
label: string;
|
||||
color: string;
|
||||
max?: number;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
showValue?: boolean;
|
||||
label?: string;
|
||||
color?: string; // Alias for intent
|
||||
}
|
||||
|
||||
export function CircularProgress({ value, max, label, color, size = 80 }: CircularProgressProps) {
|
||||
const percentage = Math.min((value / max) * 100, 100);
|
||||
const strokeWidth = 6;
|
||||
export const CircularProgress = ({
|
||||
value,
|
||||
max = 100,
|
||||
size = 64,
|
||||
strokeWidth = 4,
|
||||
intent = 'primary',
|
||||
showValue = false,
|
||||
label,
|
||||
color: colorProp
|
||||
}: CircularProgressProps) => {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
const strokeDashoffset = circumference - (percentage / 100) * circumference;
|
||||
const offset = circumference - (value / max) * circumference;
|
||||
|
||||
const intentColorMap = {
|
||||
primary: 'var(--ui-color-intent-primary)',
|
||||
success: 'var(--ui-color-intent-success)',
|
||||
warning: 'var(--ui-color-intent-warning)',
|
||||
critical: 'var(--ui-color-intent-critical)',
|
||||
telemetry: 'var(--ui-color-intent-telemetry)',
|
||||
};
|
||||
|
||||
const color = colorProp || intentColorMap[intent];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg className="transform -rotate-90" width={size} height={size}>
|
||||
<Box display="flex" flexDirection="col" alignItems="center" gap={2}>
|
||||
<Box position="relative" width={size} height={size} display="flex" alignItems="center" justifyContent="center">
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="currentColor"
|
||||
stroke="var(--ui-color-bg-surface-muted)"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
className="text-charcoal-outline"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="currentColor"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
style={{ strokeDashoffset: offset, transition: 'stroke-dashoffset 0.3s ease-in-out' }}
|
||||
strokeLinecap="round"
|
||||
className={color}
|
||||
style={{ transition: 'stroke-dashoffset 0.5s ease-in-out' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-white">{percentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 mt-2">{label}</span>
|
||||
</div>
|
||||
{showValue && (
|
||||
<Box position="absolute" inset={0} display="flex" alignItems="center" justifyContent="center">
|
||||
<Text size="xs" weight="bold" variant="high">
|
||||
{Math.round((value / max) * 100)}%
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{label && (
|
||||
<Text size="xs" weight="bold" variant="low" uppercase letterSpacing="wider">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,53 +1,26 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box, BoxProps } from './primitives/Box';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
|
||||
|
||||
interface ContainerProps extends Omit<BoxProps<'div'>, 'size' | 'padding'> {
|
||||
export interface ContainerProps {
|
||||
children: ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
padding?: boolean;
|
||||
className?: string;
|
||||
py?: Spacing;
|
||||
pb?: Spacing;
|
||||
}
|
||||
|
||||
export function Container({
|
||||
export const Container = ({
|
||||
children,
|
||||
size = 'lg',
|
||||
padding = true,
|
||||
className = '',
|
||||
py,
|
||||
pb,
|
||||
...props
|
||||
}: ContainerProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-2xl',
|
||||
md: 'max-w-4xl',
|
||||
lg: 'max-w-7xl',
|
||||
xl: 'max-w-[1400px]',
|
||||
full: 'max-w-full'
|
||||
size = 'lg'
|
||||
}: ContainerProps) => {
|
||||
const sizeMap = {
|
||||
sm: '40rem',
|
||||
md: '48rem',
|
||||
lg: '64rem',
|
||||
xl: '80rem',
|
||||
full: '100%',
|
||||
};
|
||||
|
||||
const spacingMap: Record<number, string> = {
|
||||
0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4',
|
||||
5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14',
|
||||
16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44',
|
||||
48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
'mx-auto',
|
||||
sizeClasses[size],
|
||||
padding ? 'px-4 sm:px-6 lg:px-8' : '',
|
||||
py !== undefined ? `py-${spacingMap[py]}` : '',
|
||||
pb !== undefined ? `pb-${spacingMap[pb]}` : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Box className={classes} {...props}>
|
||||
<Box marginX="auto" maxWidth={sizeMap[size]} paddingX={4} fullWidth>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
interface ContentShellProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
export interface ContentShellProps {
|
||||
children: ReactNode;
|
||||
header?: ReactNode;
|
||||
sidebar?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ContentShell is the main data zone of the application.
|
||||
* It houses the primary content and track maps/data tables.
|
||||
*/
|
||||
export function ContentShell({ children, className = '' }: ContentShellProps) {
|
||||
export const ContentShell = ({
|
||||
children,
|
||||
header,
|
||||
sidebar
|
||||
}: ContentShellProps) => {
|
||||
return (
|
||||
<main className={`flex-1 overflow-y-auto bg-[#0C0D0F] ${className}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Box display="flex" flexDirection="col" fullHeight>
|
||||
{header && (
|
||||
<Box borderBottom>
|
||||
{header}
|
||||
</Box>
|
||||
)}
|
||||
<Box display="flex" flex={1} minHeight="0">
|
||||
{sidebar && (
|
||||
<Box width="18rem" borderRight display={{ base: 'none', lg: 'block' }}>
|
||||
{sidebar}
|
||||
</Box>
|
||||
)}
|
||||
<Box flex={1} overflow="auto">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Container } from './Container';
|
||||
|
||||
interface ContentViewportProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
export interface ContentViewportProps {
|
||||
children: ReactNode;
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
/**
|
||||
* ContentViewport is the main data zone of the "Telemetry Workspace".
|
||||
* It houses the primary content, track maps, and data tables.
|
||||
* Aligned with "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function ContentViewport({ children, className = '', fullWidth = false }: ContentViewportProps) {
|
||||
export const ContentViewport = ({
|
||||
children,
|
||||
padding = 'md'
|
||||
}: ContentViewportProps) => {
|
||||
const paddingMap: Record<string, any> = {
|
||||
none: 0,
|
||||
sm: 4,
|
||||
md: 8,
|
||||
lg: 12,
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={`flex-1 overflow-y-auto bg-[#0C0D0F] ${className}`}>
|
||||
<div className={fullWidth ? '' : 'max-w-7xl mx-auto px-4 md:px-6 py-6'}>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Box as="main" flex={1} overflow="auto">
|
||||
<Container size="xl">
|
||||
<Box paddingY={paddingMap[padding]}>
|
||||
{children}
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface ControlBarProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
export interface ControlBarProps {
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ControlBar is the top-level header of the "Telemetry Workspace".
|
||||
* It provides global controls, navigation, and status information.
|
||||
* Aligned with "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function ControlBar({ children, className = '' }: ControlBarProps) {
|
||||
export const ControlBar = ({
|
||||
children,
|
||||
actions
|
||||
}: ControlBarProps) => {
|
||||
return (
|
||||
<header
|
||||
className={`sticky top-0 z-50 h-16 md:h-20 bg-[#0C0D0F]/80 backdrop-blur-md border-b border-[#23272B] flex items-center px-4 md:px-6 ${className}`}
|
||||
<Surface
|
||||
variant="muted"
|
||||
padding={4}
|
||||
style={{ borderBottom: '1px solid var(--ui-color-border-default)' }}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
{children}
|
||||
</Box>
|
||||
{actions && (
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,97 +1,36 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
|
||||
|
||||
// ISO 3166-1 alpha-2 country code to full country name mapping
|
||||
const countryNames: Record<string, string> = {
|
||||
'US': 'United States',
|
||||
'GB': 'United Kingdom',
|
||||
'CA': 'Canada',
|
||||
'AU': 'Australia',
|
||||
'NZ': 'New Zealand',
|
||||
'DE': 'Germany',
|
||||
'FR': 'France',
|
||||
'IT': 'Italy',
|
||||
'ES': 'Spain',
|
||||
'NL': 'Netherlands',
|
||||
'BE': 'Belgium',
|
||||
'SE': 'Sweden',
|
||||
'NO': 'Norway',
|
||||
'DK': 'Denmark',
|
||||
'FI': 'Finland',
|
||||
'PL': 'Poland',
|
||||
'CZ': 'Czech Republic',
|
||||
'AT': 'Austria',
|
||||
'CH': 'Switzerland',
|
||||
'PT': 'Portugal',
|
||||
'IE': 'Ireland',
|
||||
'BR': 'Brazil',
|
||||
'MX': 'Mexico',
|
||||
'AR': 'Argentina',
|
||||
'JP': 'Japan',
|
||||
'CN': 'China',
|
||||
'KR': 'South Korea',
|
||||
'IN': 'India',
|
||||
'SG': 'Singapore',
|
||||
'TH': 'Thailand',
|
||||
'MY': 'Malaysia',
|
||||
'ID': 'Indonesia',
|
||||
'PH': 'Philippines',
|
||||
'ZA': 'South Africa',
|
||||
'RU': 'Russia',
|
||||
'MC': 'Monaco',
|
||||
'TR': 'Turkey',
|
||||
'GR': 'Greece',
|
||||
'HU': 'Hungary',
|
||||
'RO': 'Romania',
|
||||
'BG': 'Bulgaria',
|
||||
'HR': 'Croatia',
|
||||
'SI': 'Slovenia',
|
||||
'SK': 'Slovakia',
|
||||
'LT': 'Lithuania',
|
||||
'LV': 'Latvia',
|
||||
'EE': 'Estonia',
|
||||
};
|
||||
|
||||
// ISO 3166-1 alpha-2 country code to flag emoji conversion
|
||||
const countryCodeToFlag = (countryCode: string): string => {
|
||||
if (!countryCode || countryCode.length !== 2) return '🏁';
|
||||
|
||||
// Convert ISO 3166-1 alpha-2 to regional indicator symbols
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
interface CountryFlagProps {
|
||||
export interface CountryFlagProps {
|
||||
countryCode: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
export function CountryFlag({
|
||||
export const CountryFlag = ({
|
||||
countryCode,
|
||||
size = 'md',
|
||||
className = '',
|
||||
showTooltip = true
|
||||
}: CountryFlagProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base'
|
||||
size = 'md'
|
||||
}: CountryFlagProps) => {
|
||||
const sizeMap = {
|
||||
sm: '1rem',
|
||||
md: '1.5rem',
|
||||
lg: '2rem',
|
||||
};
|
||||
|
||||
const flag = countryCodeToFlag(countryCode);
|
||||
const countryName = countryNames[countryCode.toUpperCase()] || countryCode;
|
||||
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center relative ${sizeClasses[size]} ${className}`}
|
||||
title={showTooltip ? countryName : undefined}
|
||||
<Box
|
||||
width={sizeMap[size]}
|
||||
height={sizeMap[size]}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
overflow="hidden"
|
||||
style={{ borderRadius: '2px' }}
|
||||
>
|
||||
<span className="select-none">{flag}</span>
|
||||
</span>
|
||||
<img
|
||||
src={`https://flagcdn.com/w40/${countryCode.toLowerCase()}.png`}
|
||||
alt={countryCode}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Heading } from './Heading';
|
||||
import { Card } from './Card';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface DangerZoneProps {
|
||||
export interface DangerZoneProps {
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DangerZone({ title, description, children }: DangerZoneProps) {
|
||||
export const DangerZone = ({
|
||||
title,
|
||||
description,
|
||||
children
|
||||
}: DangerZoneProps) => {
|
||||
return (
|
||||
<Card>
|
||||
<Heading level={3} mb={4}>Danger Zone</Heading>
|
||||
<Box p={4} rounded="lg" bg="bg-red-900/10" border={true} borderColor="border-red-900/30">
|
||||
<Text color="text-white" weight="medium" block mb={2}>{title}</Text>
|
||||
<Text size="sm" color="text-gray-400" block mb={4}>
|
||||
{description}
|
||||
</Text>
|
||||
<Box marginTop={8}>
|
||||
<Heading level={3} marginBottom={4}>Danger Zone</Heading>
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
padding={4}
|
||||
style={{ border: '1px solid var(--ui-color-intent-critical)', backgroundColor: 'rgba(227, 92, 92, 0.05)' }}
|
||||
>
|
||||
<Box marginBottom={4}>
|
||||
<Text variant="high" weight="medium" block marginBottom={2}>{title}</Text>
|
||||
<Text size="sm" variant="low" block>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
</Card>
|
||||
</Surface>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import React from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface DateHeaderProps {
|
||||
label: string;
|
||||
count?: number;
|
||||
countLabel?: string;
|
||||
export interface DateHeaderProps {
|
||||
date: string;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export function DateHeader({ label, count, countLabel = 'races' }: DateHeaderProps) {
|
||||
export const DateHeader = ({
|
||||
date,
|
||||
showIcon = true
|
||||
}: DateHeaderProps) => {
|
||||
return (
|
||||
<Stack direction="row" align="center" gap={3} px={2}>
|
||||
<Box p={2} bg="bg-primary-blue/10" rounded="lg">
|
||||
<Icon icon={Calendar} size={4} color="rgb(59, 130, 246)" />
|
||||
</Box>
|
||||
<Text weight="semibold" size="sm" color="text-white">
|
||||
{label}
|
||||
</Text>
|
||||
{count !== undefined && (
|
||||
<Text size="xs" color="text-gray-500">
|
||||
{count} {count === 1 ? countLabel.replace(/s$/, '') : countLabel}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" gap={3} paddingX={2}>
|
||||
{showIcon && (
|
||||
<Box padding={2} bg="rgba(25, 140, 255, 0.1)" rounded="lg">
|
||||
<Icon icon={Calendar} size={4} intent="primary" />
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Text weight="semibold" size="sm" variant="high">
|
||||
{date}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,71 +1,73 @@
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Input } from './Input';
|
||||
|
||||
|
||||
import { Input } from '@/ui/Input';
|
||||
|
||||
interface DurationFieldProps {
|
||||
export interface DurationFieldProps {
|
||||
label: string;
|
||||
value: number | '';
|
||||
onChange: (value: number | '') => void;
|
||||
helperText?: string;
|
||||
required?: boolean;
|
||||
value: number; // in minutes
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
unit?: 'minutes' | 'laps';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function DurationField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
helperText,
|
||||
required,
|
||||
disabled,
|
||||
unit = 'minutes',
|
||||
error,
|
||||
}: DurationFieldProps) {
|
||||
const handleChange = (raw: string) => {
|
||||
if (raw.trim() === '') {
|
||||
onChange('');
|
||||
return;
|
||||
}
|
||||
export const DurationField = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
error
|
||||
}: DurationFieldProps) => {
|
||||
const hours = Math.floor(value / 60);
|
||||
const minutes = value % 60;
|
||||
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
onChange('');
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(parsed);
|
||||
const handleHoursChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const h = parseInt(e.target.value) || 0;
|
||||
onChange(h * 60 + minutes);
|
||||
};
|
||||
|
||||
const unitLabel = unit === 'laps' ? 'laps' : 'min';
|
||||
const handleMinutesChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const m = parseInt(e.target.value) || 0;
|
||||
onChange(hours * 60 + m);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
<Box>
|
||||
<Text as="label" size="xs" weight="bold" variant="low" block marginBottom={1.5}>
|
||||
{label}
|
||||
{required && <span className="text-warning-amber ml-1">*</span>}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
</Text>
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Input
|
||||
type="number"
|
||||
value={value === '' ? '' : String(value)}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
value={hours}
|
||||
onChange={handleHoursChange}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
className="pr-16"
|
||||
variant={error ? 'error' : 'default'}
|
||||
min={0}
|
||||
style={{ width: '4rem' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 -ml-14">{unitLabel}</span>
|
||||
</div>
|
||||
{helperText && (
|
||||
<p className="text-xs text-gray-500">{helperText}</p>
|
||||
)}
|
||||
<Text size="sm" variant="low">h</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Input
|
||||
type="number"
|
||||
value={minutes}
|
||||
onChange={handleMinutesChange}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
max={59}
|
||||
style={{ width: '4rem' }}
|
||||
/>
|
||||
<Text size="sm" variant="low">m</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{error && (
|
||||
<p className="text-xs text-warning-amber mt-1">{error}</p>
|
||||
<Box marginTop={1}>
|
||||
<Text size="xs" variant="critical">
|
||||
{error}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,55 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Button } from './Button';
|
||||
import { RefreshCw, Home } from 'lucide-react';
|
||||
|
||||
interface ErrorActionButtonsProps {
|
||||
export interface ErrorActionButtonsProps {
|
||||
onRetry?: () => void;
|
||||
onHomeClick: () => void;
|
||||
showRetry?: boolean;
|
||||
homeLabel?: string;
|
||||
onGoHome?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorActionButtons
|
||||
*
|
||||
* Action buttons for error pages (Try Again, Go Home)
|
||||
* Provides consistent styling and behavior.
|
||||
* All navigation callbacks must be provided by the caller.
|
||||
*/
|
||||
export function ErrorActionButtons({
|
||||
onRetry,
|
||||
onHomeClick,
|
||||
showRetry = false,
|
||||
homeLabel = 'Drive home',
|
||||
}: ErrorActionButtonsProps) {
|
||||
if (showRetry && onRetry) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary-blue px-4 py-2 text-sm font-medium text-white hover:bg-primary-blue/80 transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onHomeClick}
|
||||
className="inline-flex items-center justify-center rounded-md bg-iron-gray px-4 py-2 text-sm font-medium text-white hover:bg-iron-gray/80 transition-colors"
|
||||
>
|
||||
Go home
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ErrorActionButtons = ({
|
||||
onRetry,
|
||||
onGoHome
|
||||
}: ErrorActionButtonsProps) => {
|
||||
return (
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onHomeClick}
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary-blue px-4 py-2 text-sm font-medium text-white hover:bg-primary-blue/80 transition-colors"
|
||||
>
|
||||
{homeLabel}
|
||||
</button>
|
||||
</div>
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
{onRetry && (
|
||||
<Button variant="primary" onClick={onRetry} icon={<RefreshCw size={16} />}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
{onGoHome && (
|
||||
<Button variant="secondary" onClick={onGoHome} icon={<Home size={16} />}>
|
||||
Go Home
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,63 +1,45 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Icon } from './Icon';
|
||||
import { AlertCircle, XCircle, Info, AlertTriangle } from 'lucide-react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface ErrorBannerProps {
|
||||
export interface ErrorBannerProps {
|
||||
title?: string;
|
||||
message: string;
|
||||
variant?: 'error' | 'warning' | 'info' | 'success';
|
||||
variant?: 'error' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
export function ErrorBanner({ title, message, variant = 'error' }: ErrorBannerProps) {
|
||||
const configs = {
|
||||
error: {
|
||||
bg: 'rgba(239, 68, 68, 0.1)',
|
||||
border: 'rgba(239, 68, 68, 0.2)',
|
||||
text: 'rgb(248, 113, 113)',
|
||||
icon: XCircle
|
||||
},
|
||||
warning: {
|
||||
bg: 'rgba(245, 158, 11, 0.1)',
|
||||
border: 'rgba(245, 158, 11, 0.2)',
|
||||
text: 'rgb(251, 191, 36)',
|
||||
icon: AlertTriangle
|
||||
},
|
||||
info: {
|
||||
bg: 'rgba(59, 130, 246, 0.1)',
|
||||
border: 'rgba(59, 130, 246, 0.2)',
|
||||
text: 'rgb(96, 165, 250)',
|
||||
icon: Info
|
||||
},
|
||||
success: {
|
||||
bg: 'rgba(16, 185, 129, 0.1)',
|
||||
border: 'rgba(16, 185, 129, 0.2)',
|
||||
text: 'rgb(52, 211, 153)',
|
||||
icon: AlertCircle
|
||||
}
|
||||
};
|
||||
|
||||
const colors = configs[variant];
|
||||
export const ErrorBanner = ({
|
||||
title,
|
||||
message,
|
||||
variant = 'error'
|
||||
}: ErrorBannerProps) => {
|
||||
const intent = variant === 'error' ? 'critical' : variant === 'warning' ? 'warning' : 'primary';
|
||||
const color = variant === 'error' ? 'rgba(227, 92, 92, 0.05)' : variant === 'warning' ? 'rgba(255, 190, 77, 0.05)' : 'rgba(25, 140, 255, 0.05)';
|
||||
const borderColor = variant === 'error' ? 'rgba(227, 92, 92, 0.2)' : variant === 'warning' ? 'rgba(255, 190, 77, 0.2)' : 'rgba(25, 140, 255, 0.2)';
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
border
|
||||
p={4}
|
||||
backgroundColor={colors.bg}
|
||||
borderColor={colors.border}
|
||||
rounded="lg"
|
||||
padding={4}
|
||||
style={{ backgroundColor: color, border: `1px solid ${borderColor}` }}
|
||||
>
|
||||
<Stack direction="row" align="start" gap={3}>
|
||||
<Icon icon={colors.icon} size={5} color={colors.text} />
|
||||
<Box flex={1}>
|
||||
{title && <Text weight="medium" color={colors.text} block mb={1}>{title}</Text>}
|
||||
<Text size="sm" color={colors.text} opacity={0.9} block>{message}</Text>
|
||||
<Box display="flex" alignItems="start" gap={4}>
|
||||
<Icon icon={AlertTriangle} size={5} intent={intent} />
|
||||
<Box>
|
||||
{title && (
|
||||
<Text weight="medium" variant="high" block marginBottom={1}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" variant="low">
|
||||
{message}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface ErrorPageContainerProps {
|
||||
export interface ErrorPageContainerProps {
|
||||
children: ReactNode;
|
||||
errorCode: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorPageContainer
|
||||
*
|
||||
* A reusable container for error pages (404, 500, etc.)
|
||||
* Provides consistent styling and layout for error states.
|
||||
*/
|
||||
export function ErrorPageContainer({
|
||||
children,
|
||||
errorCode,
|
||||
description,
|
||||
}: ErrorPageContainerProps) {
|
||||
export const ErrorPageContainer = ({ children }: ErrorPageContainerProps) => {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-deep-graphite text-white px-6">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<h1 className="text-3xl font-semibold">{errorCode}</h1>
|
||||
<p className="text-sm text-gray-400">{description}</p>
|
||||
<Box
|
||||
minHeight="100vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding={4}
|
||||
bg="var(--ui-color-bg-base)"
|
||||
>
|
||||
<Surface variant="default" rounded="xl" padding={8} style={{ maxWidth: '32rem', width: '100%', border: '1px solid var(--ui-color-border-default)' }}>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</Surface>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
export function FeedEmptyState() {
|
||||
return (
|
||||
<Card bg="bg-iron-gray/80" border={true} borderColor="border-charcoal-outline" className="border-dashed">
|
||||
<Box textAlign="center" py={10}>
|
||||
<Text size="3xl" block mb={3}>🏁</Text>
|
||||
<Box mb={2}>
|
||||
<Heading level={3}>
|
||||
Your feed is warming up
|
||||
</Heading>
|
||||
</Box>
|
||||
<Box maxWidth="md" mx="auto" mb={4}>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
As leagues, teams, and friends start racing, this feed will show their latest results,
|
||||
signups, and highlights.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
as="a"
|
||||
href="/leagues"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Explore leagues
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
export interface FeedEmptyStateProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const FeedEmptyState = ({
|
||||
message = 'No activity yet.'
|
||||
}: FeedEmptyStateProps) => {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
paddingY={12}
|
||||
textAlign="center"
|
||||
>
|
||||
<Box padding={4} rounded="full" bg="var(--ui-color-bg-surface-muted)" marginBottom={4}>
|
||||
<Icon icon={MessageSquare} size={8} intent="low" />
|
||||
</Box>
|
||||
<Text variant="low">{message}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,77 +1,46 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Image } from './Image';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
interface FeedItemProps {
|
||||
actorName?: string;
|
||||
actorAvatarUrl?: string;
|
||||
typeLabel: string;
|
||||
headline: string;
|
||||
body?: string;
|
||||
timeAgo: string;
|
||||
cta?: ReactNode;
|
||||
export interface FeedItemProps {
|
||||
user: {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
content: ReactNode;
|
||||
timestamp: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function FeedItem({
|
||||
actorName,
|
||||
actorAvatarUrl,
|
||||
typeLabel,
|
||||
headline,
|
||||
body,
|
||||
timeAgo,
|
||||
cta,
|
||||
}: FeedItemProps) {
|
||||
export const FeedItem = ({
|
||||
user,
|
||||
content,
|
||||
timestamp,
|
||||
actions
|
||||
}: FeedItemProps) => {
|
||||
return (
|
||||
<Box display="flex" gap={4}>
|
||||
<Box flexShrink={0}>
|
||||
{actorAvatarUrl ? (
|
||||
<Box width="10" height="10" rounded="full" overflow="hidden" bg="bg-charcoal-outline">
|
||||
<Image
|
||||
src={actorAvatarUrl}
|
||||
alt={actorName || ''}
|
||||
width={40}
|
||||
height={40}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
<Surface variant="default" rounded="lg" padding={4} style={{ border: '1px solid var(--ui-color-border-default)' }}>
|
||||
<Box display="flex" alignItems="start" gap={4}>
|
||||
<Avatar src={user.avatar} alt={user.name} size="md" />
|
||||
<Box flex={1}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" marginBottom={2}>
|
||||
<Text weight="bold" variant="high">{user.name}</Text>
|
||||
<Text size="xs" variant="low">{timestamp}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
width="10"
|
||||
height="10"
|
||||
display="flex"
|
||||
center
|
||||
rounded="full"
|
||||
bg="bg-primary-blue/10"
|
||||
border={true}
|
||||
borderColor="border-primary-blue/40"
|
||||
>
|
||||
<Text size="xs" color="text-primary-blue" weight="semibold">
|
||||
{typeLabel}
|
||||
</Text>
|
||||
<Box marginBottom={4}>
|
||||
{typeof content === 'string' ? (
|
||||
<Text variant="med">{content}</Text>
|
||||
) : content}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Box display="flex" alignItems="start" justifyContent="between" gap={2}>
|
||||
<Box>
|
||||
<Text size="sm" color="text-white" block>{headline}</Text>
|
||||
{body && (
|
||||
<Text size="xs" color="text-gray-400" block mt={1}>{body}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text size="xs" color="text-gray-500" className="whitespace-nowrap" style={{ fontSize: '11px' }}>
|
||||
{timeAgo}
|
||||
</Text>
|
||||
{actions && (
|
||||
<Box display="flex" alignItems="center" gap={4} borderTop paddingTop={4}>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{cta && (
|
||||
<Box mt={3}>
|
||||
{cta}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,46 +1,61 @@
|
||||
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Button } from './Button';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface FilterOption {
|
||||
export interface FilterOption {
|
||||
id: string;
|
||||
label: string;
|
||||
indicatorColor?: string;
|
||||
}
|
||||
|
||||
interface FilterGroupProps {
|
||||
export interface FilterGroupProps {
|
||||
options: FilterOption[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onChange: (id: string) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function FilterGroup({ options, activeId, onSelect }: FilterGroupProps) {
|
||||
export const FilterGroup = ({
|
||||
options,
|
||||
activeId,
|
||||
onChange,
|
||||
label
|
||||
}: FilterGroupProps) => {
|
||||
return (
|
||||
<Stack direction="row" align="center" gap={1} bg="bg-deep-graphite" p={1} rounded="lg">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
variant={activeId === option.id ? 'primary' : 'ghost'}
|
||||
onClick={() => onSelect(option.id)}
|
||||
size="sm"
|
||||
px={4}
|
||||
>
|
||||
{option.indicatorColor && (
|
||||
<Box
|
||||
as="span"
|
||||
w="2"
|
||||
h="2"
|
||||
bg={option.indicatorColor}
|
||||
rounded="full"
|
||||
mr={2}
|
||||
animate={option.indicatorColor.includes('green') ? 'pulse' : 'none'}
|
||||
/>
|
||||
)}
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
{label && (
|
||||
<Text size="xs" weight="bold" variant="low" uppercase letterSpacing="wider">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<Surface variant="muted" rounded="lg" padding={1} display="flex" gap={1}>
|
||||
{options.map((option) => {
|
||||
const isActive = option.id === activeId;
|
||||
return (
|
||||
<Button
|
||||
key={option.id}
|
||||
variant={isActive ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onChange(option.id)}
|
||||
fullWidth
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{option.indicatorColor && (
|
||||
<Box
|
||||
width="0.5rem"
|
||||
height="0.5rem"
|
||||
rounded="full"
|
||||
style={{ backgroundColor: option.indicatorColor }}
|
||||
/>
|
||||
)}
|
||||
{option.label}
|
||||
</Box>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Surface>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,81 +1,54 @@
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Container } from './Container';
|
||||
import { Text } from './Text';
|
||||
import { Link } from './Link';
|
||||
|
||||
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || 'https://discord.gg/gridpilot';
|
||||
const xUrl = process.env.NEXT_PUBLIC_X_URL || '#';
|
||||
|
||||
export function Footer() {
|
||||
export const Footer = () => {
|
||||
return (
|
||||
<Box as="footer" position="relative" bg="graphite-black" borderTop borderColor="border-gray/50">
|
||||
<Box position="absolute" top="0" left="0" right="0" h="px" bg="linear-gradient(to right, transparent, #198CFF, transparent)" opacity={0.3} />
|
||||
|
||||
<Box maxWidth="7xl" mx="auto" px={{ base: 6, lg: 8 }} py={{ base: 12, md: 16 }}>
|
||||
{/* Racing stripe accent */}
|
||||
<Box
|
||||
display="flex"
|
||||
gap={2}
|
||||
mb={8}
|
||||
justifyContent="center"
|
||||
>
|
||||
<Box w="12" h="1" bg="white" opacity={0.1} />
|
||||
<Box w="12" h="1" bg="primary-accent" />
|
||||
<Box w="12" h="1" bg="white" opacity={0.1} />
|
||||
<Box as="footer" bg="var(--ui-color-bg-surface)" borderTop paddingY={12}>
|
||||
<Container size="xl">
|
||||
<Box display="grid" gridCols={{ base: 1, md: 4 }} gap={12}>
|
||||
<Box>
|
||||
<Text weight="bold" variant="high" marginBottom={4}>GridPilot</Text>
|
||||
<Text size="sm" variant="low">
|
||||
The ultimate companion for sim racers. Track your performance, manage your team, and compete in leagues.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text weight="bold" variant="high" marginBottom={4}>Platform</Text>
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<Link href="/leagues" variant="secondary">Leagues</Link>
|
||||
<Link href="/teams" variant="secondary">Teams</Link>
|
||||
<Link href="/leaderboards" variant="secondary">Leaderboards</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text weight="bold" variant="high" marginBottom={4}>Support</Text>
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<Link href="/docs" variant="secondary">Documentation</Link>
|
||||
<Link href="/status" variant="secondary">System Status</Link>
|
||||
<Link href="/contact" variant="secondary">Contact Us</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text weight="bold" variant="high" marginBottom={4}>Legal</Text>
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<Link href="/privacy" variant="secondary">Privacy Policy</Link>
|
||||
<Link href="/terms" variant="secondary">Terms of Service</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Personal message */}
|
||||
<Box
|
||||
textAlign="center"
|
||||
mb={12}
|
||||
>
|
||||
<Text size="sm" color="text-gray-300" block mb={2} weight="bold" className="tracking-wide">
|
||||
🏁 Built by a sim racer, for sim racers
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" weight="normal" maxWidth="2xl" mx="auto" block leading="relaxed">
|
||||
Just a fellow racer tired of spreadsheets and chaos. GridPilot is my passion project to make league racing actually fun again.
|
||||
|
||||
<Box borderTop marginTop={12} paddingTop={8} textAlign="center">
|
||||
<Text size="xs" variant="low">
|
||||
© {new Date().getFullYear()} GridPilot. All rights reserved.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Community links */}
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
gap={8}
|
||||
mb={12}
|
||||
>
|
||||
<Link
|
||||
href={discordUrl}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-400 hover:text-primary-accent transition-colors font-bold uppercase tracking-widest"
|
||||
>
|
||||
💬 Discord
|
||||
</Link>
|
||||
<Link
|
||||
href={xUrl}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-400 hover:text-primary-accent transition-colors font-bold uppercase tracking-widest"
|
||||
>
|
||||
𝕏 Twitter
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
{/* Development status */}
|
||||
<Box
|
||||
textAlign="center"
|
||||
pt={8}
|
||||
borderTop
|
||||
borderColor="border-gray/30"
|
||||
>
|
||||
<Text size="xs" color="text-gray-600" block mb={1} font="mono" uppercase letterSpacing="widest">
|
||||
⚡ Early development • Feedback welcome
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-700" block font="mono">
|
||||
© {new Date().getFullYear()} GridPilot
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,45 +1,55 @@
|
||||
|
||||
|
||||
import React from 'react';
|
||||
import { Icon } from './Icon';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
children: React.ReactNode;
|
||||
required?: boolean;
|
||||
export interface FormFieldProps {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
icon?: LucideIcon;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
export const FormField = ({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
required,
|
||||
icon,
|
||||
children,
|
||||
required = false,
|
||||
error,
|
||||
hint,
|
||||
}: FormFieldProps) {
|
||||
children
|
||||
}: FormFieldProps) => {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{icon && <Icon icon={icon} size={4} color="#6b7280" />}
|
||||
<Text size="sm" weight="medium" color="text-gray-300">{label}</Text>
|
||||
{required && <Text color="text-error-red">*</Text>}
|
||||
</Stack>
|
||||
</label>
|
||||
<Box marginBottom={4}>
|
||||
{label && (
|
||||
<Box display="flex" alignItems="center" gap={2} marginBottom={1.5}>
|
||||
{icon && <Icon icon={icon} size={4} intent="low" />}
|
||||
<Text size="sm" weight="medium" variant="high">
|
||||
{label}
|
||||
</Text>
|
||||
{required && <Text variant="critical">*</Text>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{error && (
|
||||
<Text size="xs" color="text-error-red" block mt={1}>{error}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text size="xs" variant="critical" block>
|
||||
{error}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hint && !error && (
|
||||
<Text size="xs" color="text-gray-500" block mt={1}>{hint}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text size="xs" variant="low" block>
|
||||
{hint}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,37 +1,33 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface FormSectionProps {
|
||||
export interface FormSectionProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FormSection
|
||||
*
|
||||
* Groups related form fields with an optional title.
|
||||
*/
|
||||
export function FormSection({ children, title }: FormSectionProps) {
|
||||
export const FormSection = ({
|
||||
title,
|
||||
description,
|
||||
children
|
||||
}: FormSectionProps) => {
|
||||
return (
|
||||
<Stack gap={4} fullWidth>
|
||||
{title && (
|
||||
<Text
|
||||
size="xs"
|
||||
weight="bold"
|
||||
color="text-gray-500"
|
||||
uppercase
|
||||
letterSpacing="widest"
|
||||
borderBottom
|
||||
borderColor="border-border-gray"
|
||||
pb={1}
|
||||
>
|
||||
<Box display="flex" flexDirection="col" gap={6}>
|
||||
<Box borderBottom paddingBottom={4}>
|
||||
<Text weight="bold" variant="high" size="lg" marginBottom={1} block>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Stack gap={4} fullWidth>
|
||||
{description && (
|
||||
<Text size="sm" variant="low">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box display="flex" flexDirection="col" gap={4}>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,41 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { Heading } from './Heading';
|
||||
import { Card } from './Card';
|
||||
import { ProgressBar } from './ProgressBar';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface GoalCardProps {
|
||||
export interface GoalCardProps {
|
||||
title: string;
|
||||
current: number;
|
||||
target: number;
|
||||
unit: string;
|
||||
icon: string;
|
||||
goalLabel: string;
|
||||
currentValue: number;
|
||||
maxValue: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function GoalCard({
|
||||
title,
|
||||
icon,
|
||||
goalLabel,
|
||||
currentValue,
|
||||
maxValue,
|
||||
color = 'text-primary-blue',
|
||||
}: GoalCardProps) {
|
||||
export const GoalCard = ({
|
||||
title,
|
||||
current,
|
||||
target,
|
||||
unit,
|
||||
icon
|
||||
}: GoalCardProps) => {
|
||||
const percentage = Math.min(Math.max((current / target) * 100, 0), 100);
|
||||
|
||||
return (
|
||||
<Card bg="bg-charcoal-200/50" borderColor="border-primary-blue/30">
|
||||
<Box display="flex" alignItems="center" gap={3} mb={3}>
|
||||
<Card variant="default">
|
||||
<Box display="flex" alignItems="center" gap={3} marginBottom={3}>
|
||||
<Text size="2xl">{icon}</Text>
|
||||
<Heading level={3}>{title}</Heading>
|
||||
</Box>
|
||||
<Stack gap={2}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Text size="sm" color="text-gray-400">{goalLabel}</Text>
|
||||
<Text size="sm" className={color}>{currentValue}/{maxValue}</Text>
|
||||
<Box>
|
||||
<Text weight="bold" variant="high">{title}</Text>
|
||||
<Text size="sm" variant="low">{unit}</Text>
|
||||
</Box>
|
||||
<ProgressBar value={currentValue} max={maxValue} />
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={2}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" marginBottom={1}>
|
||||
<Text size="xs" variant="low">Progress</Text>
|
||||
<Text size="xs" weight="bold" variant="high">{Math.round(percentage)}%</Text>
|
||||
</Box>
|
||||
<Box fullWidth height="0.5rem" bg="var(--ui-color-bg-surface-muted)" style={{ borderRadius: '9999px', overflow: 'hidden' }}>
|
||||
<Box fullHeight bg="var(--ui-color-intent-primary)" style={{ width: `${percentage}%` }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Text size="xs" variant="low">
|
||||
{current} / {target} {unit}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Container } from '@/ui/Container';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Container } from './Container';
|
||||
|
||||
interface HeaderProps {
|
||||
children: React.ReactNode;
|
||||
export interface HeaderProps {
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function Header({ children }: HeaderProps) {
|
||||
export const Header = ({
|
||||
children,
|
||||
actions
|
||||
}: HeaderProps) => {
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-graphite-black/80 backdrop-blur-md border-b border-border-gray/50">
|
||||
<Container>
|
||||
{children}
|
||||
<Box
|
||||
as="header"
|
||||
bg="var(--ui-color-bg-surface)"
|
||||
borderBottom
|
||||
paddingY={4}
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex={50}
|
||||
>
|
||||
<Container size="xl">
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Box display="flex" alignItems="center" gap={8}>
|
||||
{children}
|
||||
</Box>
|
||||
{actions && (
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
</header>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,93 +1,52 @@
|
||||
import React, { ReactNode, ElementType } from 'react';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import React, { ReactNode, forwardRef } from 'react';
|
||||
import { Box, BoxProps, ResponsiveValue } from './primitives/Box';
|
||||
|
||||
interface ResponsiveFontSize {
|
||||
base?: string;
|
||||
sm?: string;
|
||||
md?: string;
|
||||
lg?: string;
|
||||
xl?: string;
|
||||
'2xl'?: string;
|
||||
}
|
||||
|
||||
interface HeadingProps extends Omit<BoxProps<'h1'>, 'children' | 'as' | 'fontSize'> {
|
||||
level: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
export interface HeadingProps extends BoxProps<any> {
|
||||
children: ReactNode;
|
||||
icon?: ReactNode;
|
||||
id?: string;
|
||||
groupHoverColor?: string;
|
||||
truncate?: boolean;
|
||||
uppercase?: boolean;
|
||||
fontSize?: string | ResponsiveFontSize;
|
||||
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold' | string;
|
||||
letterSpacing?: string;
|
||||
level?: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
weight?: 'normal' | 'medium' | 'semibold' | 'bold';
|
||||
align?: 'left' | 'center' | 'right';
|
||||
fontSize?: string | ResponsiveValue<string>;
|
||||
}
|
||||
|
||||
export function Heading({ level, children, icon, groupHoverColor, truncate, uppercase, fontSize, weight, letterSpacing, ...props }: HeadingProps) {
|
||||
const Tag = `h${level}` as ElementType;
|
||||
export const Heading = forwardRef<HTMLHeadingElement, HeadingProps>(({
|
||||
children,
|
||||
level = 1,
|
||||
weight = 'bold',
|
||||
align = 'left',
|
||||
fontSize,
|
||||
...props
|
||||
}, ref) => {
|
||||
const Tag = `h${level}` as const;
|
||||
|
||||
const levelClasses = {
|
||||
1: 'text-3xl md:text-4xl font-bold text-white tracking-tight',
|
||||
2: 'text-xl md:text-2xl font-bold text-white tracking-tight',
|
||||
3: 'text-lg font-bold text-white tracking-tight',
|
||||
4: 'text-base font-bold text-white tracking-tight',
|
||||
5: 'text-sm font-bold text-white tracking-tight uppercase tracking-wider',
|
||||
6: 'text-xs font-bold text-white tracking-tight uppercase tracking-widest',
|
||||
};
|
||||
|
||||
const weightClasses: Record<string, string> = {
|
||||
light: 'font-light',
|
||||
const weightClasses = {
|
||||
normal: 'font-normal',
|
||||
medium: 'font-medium',
|
||||
semibold: 'font-semibold',
|
||||
bold: 'font-bold'
|
||||
};
|
||||
|
||||
const getFontSizeClasses = (value: string | ResponsiveFontSize | undefined) => {
|
||||
if (value === undefined) return '';
|
||||
if (typeof value === 'object') {
|
||||
const classes = [];
|
||||
if (value.base) classes.push(`text-${value.base}`);
|
||||
if (value.sm) classes.push(`sm:text-${value.sm}`);
|
||||
if (value.md) classes.push(`md:text-${value.md}`);
|
||||
if (value.lg) classes.push(`lg:text-${value.lg}`);
|
||||
if (value.xl) classes.push(`xl:text-${value.xl}`);
|
||||
if (value['2xl']) classes.push(`2xl:text-${value['2xl']}`);
|
||||
return classes.join(' ');
|
||||
}
|
||||
return `text-${value}`;
|
||||
|
||||
const sizeClasses = {
|
||||
1: 'text-4xl md:text-5xl',
|
||||
2: 'text-3xl md:text-4xl',
|
||||
3: 'text-2xl md:text-3xl',
|
||||
4: 'text-xl md:text-2xl',
|
||||
5: 'text-lg md:text-xl',
|
||||
6: 'text-base md:text-lg'
|
||||
};
|
||||
|
||||
const content = icon ? (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{icon}
|
||||
{children}
|
||||
</Stack>
|
||||
) : children;
|
||||
|
||||
const classes = [
|
||||
levelClasses[level],
|
||||
getFontSizeClasses(fontSize),
|
||||
weight && weightClasses[weight as keyof typeof weightClasses] ? weightClasses[weight as keyof typeof weightClasses] : '',
|
||||
letterSpacing ? `tracking-${letterSpacing}` : '',
|
||||
uppercase ? 'uppercase' : '',
|
||||
groupHoverColor ? `group-hover:text-${groupHoverColor}` : '',
|
||||
truncate ? 'truncate' : '',
|
||||
props.className
|
||||
].filter(Boolean).join(' ');
|
||||
'text-[var(--ui-color-text-high)]',
|
||||
weightClasses[weight],
|
||||
fontSize ? '' : sizeClasses[level],
|
||||
align === 'center' ? 'text-center' : (align === 'right' ? 'text-right' : 'text-left'),
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<Box
|
||||
as={Tag}
|
||||
{...props}
|
||||
className={classes}
|
||||
style={{
|
||||
...(weight && !weightClasses[weight as keyof typeof weightClasses] ? { fontWeight: weight } : {}),
|
||||
...(props.style || {})
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
<Box as={Tag} ref={ref} className={classes} fontSize={typeof fontSize === 'string' ? fontSize : undefined} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Heading.displayName = 'Heading';
|
||||
|
||||
@@ -1,28 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './primitives/Stack';
|
||||
|
||||
|
||||
|
||||
interface BarChartProps {
|
||||
data: { label: string; value: number; color: string }[];
|
||||
maxValue: number;
|
||||
export interface HorizontalBarChartItem {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function HorizontalBarChart({ data, maxValue }: BarChartProps) {
|
||||
export interface HorizontalBarChartProps {
|
||||
items?: HorizontalBarChartItem[];
|
||||
total?: number;
|
||||
data?: HorizontalBarChartItem[]; // Alias for items
|
||||
maxValue?: number; // Alias for total
|
||||
}
|
||||
|
||||
export const HorizontalBarChart = ({
|
||||
items,
|
||||
total,
|
||||
data,
|
||||
maxValue
|
||||
}: HorizontalBarChartProps) => {
|
||||
const actualItems = items || data || [];
|
||||
const actualTotal = total || maxValue || actualItems.reduce((acc, item) => acc + item.value, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{data.map((item) => (
|
||||
<div key={item.label}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-400">{item.label}</span>
|
||||
<span className="text-white font-medium">{item.value}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-charcoal-outline rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${item.color} transition-all duration-500 ease-out`}
|
||||
style={{ width: `${Math.min((item.value / maxValue) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Box display="flex" flexDirection="col" gap={4}>
|
||||
{actualItems.map((item, index) => {
|
||||
const percentage = actualTotal > 0 ? (item.value / actualTotal) * 100 : 0;
|
||||
return (
|
||||
<Box key={index}>
|
||||
<Stack direction="row" justify="between" marginBottom={1}>
|
||||
<Text size="xs" variant="low" uppercase weight="bold">{item.label}</Text>
|
||||
<Text size="xs" variant="high" weight="bold">{item.value}</Text>
|
||||
</Stack>
|
||||
<Box fullWidth height="0.5rem" bg="var(--ui-color-bg-surface-muted)" style={{ borderRadius: '9999px', overflow: 'hidden' }}>
|
||||
<Box
|
||||
fullHeight
|
||||
bg={item.color || 'var(--ui-color-intent-primary)'}
|
||||
style={{ width: `${percentage}%`, transition: 'width 0.3s ease-in-out' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,46 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Card } from './Card';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface HorizontalStatCardProps {
|
||||
export interface HorizontalStatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
iconColor?: string;
|
||||
iconBgColor?: string;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
}
|
||||
|
||||
export function HorizontalStatCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
iconColor = 'text-primary-blue',
|
||||
iconBgColor = 'rgba(59, 130, 246, 0.1)',
|
||||
}: HorizontalStatCardProps) {
|
||||
export const HorizontalStatCard = ({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
intent = 'primary'
|
||||
}: HorizontalStatCardProps) => {
|
||||
return (
|
||||
<Surface variant="muted" rounded="xl" border p={4}>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
p={3}
|
||||
backgroundColor={iconBgColor}
|
||||
>
|
||||
<Icon icon={icon} size={5} color={iconColor} />
|
||||
</Surface>
|
||||
<Card variant="default">
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box padding={3} rounded="lg" bg="var(--ui-color-bg-surface-muted)">
|
||||
<Icon icon={icon} size={5} intent={intent} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" color="text-gray-500" uppercase letterSpacing="wider" block>
|
||||
<Text size="xs" weight="bold" variant="low" uppercase>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xl" weight="bold" color="text-white" block>
|
||||
<Text size="xl" weight="bold" variant="high" block marginTop={0.5}>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,17 +2,21 @@ import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface HorizontalStatItemProps {
|
||||
export interface HorizontalStatItemProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
color?: string;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'high' | 'med' | 'low';
|
||||
}
|
||||
|
||||
export function HorizontalStatItem({ label, value, color = 'text-white' }: HorizontalStatItemProps) {
|
||||
export const HorizontalStatItem = ({
|
||||
label,
|
||||
value,
|
||||
intent = 'high'
|
||||
}: HorizontalStatItemProps) => {
|
||||
return (
|
||||
<Box display="flex" alignItems="center" justifyContent="between" fullWidth>
|
||||
<Text size="sm" color="text-gray-400">{label}</Text>
|
||||
<Text weight="semibold" color={color}>{value}</Text>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" paddingY={2}>
|
||||
<Text size="sm" variant="low">{label}</Text>
|
||||
<Text weight="semibold" variant={intent}>{value}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,77 +4,71 @@ import { Box, BoxProps } from './primitives/Box';
|
||||
|
||||
export interface IconProps extends Omit<BoxProps<'div'>, 'children'> {
|
||||
icon: LucideIcon | React.ReactNode;
|
||||
size?: number | string;
|
||||
color?: string;
|
||||
strokeWidth?: number;
|
||||
animate?: string;
|
||||
transition?: boolean;
|
||||
groupHoverTextColor?: string;
|
||||
groupHoverScale?: boolean;
|
||||
size?: 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 10 | 12 | 16 | 'full' | number;
|
||||
intent?: 'primary' | 'telemetry' | 'warning' | 'success' | 'critical' | 'high' | 'med' | 'low';
|
||||
animate?: 'spin' | 'none';
|
||||
}
|
||||
|
||||
export function Icon({
|
||||
icon: IconProp,
|
||||
size = 4,
|
||||
color,
|
||||
className = '',
|
||||
style,
|
||||
animate,
|
||||
transition,
|
||||
groupHoverTextColor,
|
||||
groupHoverScale,
|
||||
intent,
|
||||
animate = 'none',
|
||||
...props
|
||||
}: IconProps) {
|
||||
const sizeMap: Record<string | number, string> = {
|
||||
3: 'w-3 h-3',
|
||||
3.5: 'w-3.5 h-3.5',
|
||||
4: 'w-4 h-4',
|
||||
5: 'w-5 h-5',
|
||||
6: 'w-6 h-6',
|
||||
7: 'w-7 h-7',
|
||||
8: 'w-8 h-8',
|
||||
10: 'w-10 h-10',
|
||||
12: 'w-12 h-12',
|
||||
16: 'w-16 h-16',
|
||||
'full': 'w-full h-full'
|
||||
3: '0.75rem',
|
||||
3.5: '0.875rem',
|
||||
4: '1rem',
|
||||
5: '1.25rem',
|
||||
6: '1.5rem',
|
||||
7: '1.75rem',
|
||||
8: '2rem',
|
||||
10: '2.5rem',
|
||||
12: '3rem',
|
||||
16: '4rem',
|
||||
'full': '100%'
|
||||
};
|
||||
|
||||
const sizeClass = sizeMap[size] || 'w-4 h-4';
|
||||
|
||||
// If color starts with 'text-', it's a tailwind class, so pass it as color prop to Box
|
||||
const isTailwindColor = typeof color === 'string' && color.startsWith('text-');
|
||||
const combinedStyle = color && !isTailwindColor ? { color, ...style } : style;
|
||||
const boxColor = isTailwindColor ? color : undefined;
|
||||
const dimension = typeof size === 'string' ? sizeMap[size] : (sizeMap[size] || `${size * 0.25}rem`);
|
||||
|
||||
const classes = [
|
||||
sizeClass,
|
||||
animate === 'spin' ? 'animate-spin' : '',
|
||||
transition ? 'transition-all duration-150' : '',
|
||||
groupHoverTextColor ? `group-hover:text-${groupHoverTextColor}` : '',
|
||||
groupHoverScale ? 'group-hover:scale-110 transition-transform' : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
const intentColorMap: Record<string, string> = {
|
||||
primary: 'var(--ui-color-intent-primary)',
|
||||
telemetry: 'var(--ui-color-intent-telemetry)',
|
||||
warning: 'var(--ui-color-intent-warning)',
|
||||
success: 'var(--ui-color-intent-success)',
|
||||
critical: 'var(--ui-color-intent-critical)',
|
||||
high: 'var(--ui-color-text-high)',
|
||||
med: 'var(--ui-color-text-med)',
|
||||
low: 'var(--ui-color-text-low)',
|
||||
};
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
width: dimension,
|
||||
height: dimension,
|
||||
color: intent ? intentColorMap[intent] : undefined,
|
||||
};
|
||||
|
||||
const renderIcon = () => {
|
||||
if (!IconProp) return null;
|
||||
if (typeof IconProp === 'function' || (typeof IconProp === 'object' && 'render' in IconProp)) {
|
||||
const LucideIconComponent = IconProp as LucideIcon;
|
||||
return <LucideIconComponent size="100%" strokeWidth={props.strokeWidth} />;
|
||||
return <LucideIconComponent size="100%" />;
|
||||
}
|
||||
return IconProp;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classes}
|
||||
style={combinedStyle}
|
||||
color={boxColor}
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
{renderIcon()}
|
||||
<div className={animate === 'spin' ? 'animate-spin w-full h-full flex items-center justify-center' : 'w-full h-full flex items-center justify-center'}>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,31 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import React, { MouseEventHandler } from 'react';
|
||||
import { Button, ButtonProps } from './Button';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface IconButtonProps {
|
||||
icon: LucideIcon;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
export interface IconButtonProps extends Omit<ButtonProps, 'children' | 'icon'> {
|
||||
icon: any;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
color?: string;
|
||||
className?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
icon,
|
||||
onClick,
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
export const IconButton = ({
|
||||
icon,
|
||||
title,
|
||||
disabled,
|
||||
color,
|
||||
className = '',
|
||||
backgroundColor,
|
||||
}: IconButtonProps) {
|
||||
const sizeMap = {
|
||||
sm: { w: '8', h: '8', icon: 4 },
|
||||
md: { w: '10', h: '10', icon: 5 },
|
||||
lg: { w: '12', h: '12', icon: 6 },
|
||||
};
|
||||
size = 'md',
|
||||
...props
|
||||
}: IconButtonProps) => {
|
||||
const iconSizeMap = {
|
||||
sm: 3,
|
||||
md: 4,
|
||||
lg: 5
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
w={sizeMap[size].w}
|
||||
h={sizeMap[size].h}
|
||||
p={0}
|
||||
rounded="full"
|
||||
display="flex"
|
||||
center
|
||||
minHeight="0"
|
||||
className={className}
|
||||
backgroundColor={backgroundColor}
|
||||
<Button
|
||||
size={size}
|
||||
{...props}
|
||||
>
|
||||
<Icon icon={icon} size={sizeMap[size].icon} color={color} />
|
||||
<Icon icon={icon} size={iconSizeMap[size]} />
|
||||
{title && <span className="sr-only">{title}</span>}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,53 +1,36 @@
|
||||
import React, { ImgHTMLAttributes } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Box, BoxProps } from './primitives/Box';
|
||||
import { ImagePlaceholder } from './ImagePlaceholder';
|
||||
|
||||
interface ImageProps extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
export interface ImageProps extends Omit<BoxProps<'img'>, 'children'> {
|
||||
src: string;
|
||||
alt: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
fallbackSrc?: string;
|
||||
objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
|
||||
fill?: boolean;
|
||||
fullWidth?: boolean;
|
||||
fullHeight?: boolean;
|
||||
fallbackComponent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Image({
|
||||
export const Image = ({
|
||||
src,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
className = '',
|
||||
fallbackSrc,
|
||||
objectFit,
|
||||
fill,
|
||||
fullWidth,
|
||||
fullHeight,
|
||||
fallbackSrc,
|
||||
fallbackComponent,
|
||||
...props
|
||||
}: ImageProps) {
|
||||
const classes = [
|
||||
objectFit ? `object-${objectFit}` : '',
|
||||
fill ? 'absolute inset-0 w-full h-full' : '',
|
||||
fullWidth ? 'w-full' : '',
|
||||
fullHeight ? 'h-full' : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
}: ImageProps) => {
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
if (error) {
|
||||
if (fallbackComponent) return <>{fallbackComponent}</>;
|
||||
if (fallbackSrc) return <Box as="img" src={fallbackSrc} alt={alt} {...props} />;
|
||||
return <ImagePlaceholder />;
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={classes}
|
||||
onError={(e) => {
|
||||
if (fallbackSrc) {
|
||||
(e.target as HTMLImageElement).src = fallbackSrc;
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
<Box
|
||||
as="img"
|
||||
src={src}
|
||||
alt={alt}
|
||||
onError={() => setError(true)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,86 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Image as ImageIcon, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Icon } from './Icon';
|
||||
import { Text } from './Text';
|
||||
import { Image as ImageIcon } from 'lucide-react';
|
||||
|
||||
export interface ImagePlaceholderProps {
|
||||
size?: number | string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
animate?: 'pulse' | 'none' | 'spin';
|
||||
aspectRatio?: string;
|
||||
variant?: 'default' | 'error' | 'loading';
|
||||
message?: string;
|
||||
className?: string;
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
||||
}
|
||||
|
||||
export function ImagePlaceholder({
|
||||
size = 'full',
|
||||
aspectRatio = '1/1',
|
||||
variant = 'default',
|
||||
message,
|
||||
className = '',
|
||||
rounded = 'md',
|
||||
}: ImagePlaceholderProps) {
|
||||
const config = {
|
||||
default: {
|
||||
icon: ImageIcon,
|
||||
color: 'text-gray-500',
|
||||
bg: 'bg-charcoal-outline/20',
|
||||
borderColor: 'border-charcoal-outline/50',
|
||||
animate: undefined as 'spin' | 'pulse' | 'bounce' | 'fade-in' | 'none' | undefined,
|
||||
},
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
color: 'text-amber-500',
|
||||
bg: 'bg-amber-500/5',
|
||||
borderColor: 'border-amber-500/20',
|
||||
animate: undefined as 'spin' | 'pulse' | 'bounce' | 'fade-in' | 'none' | undefined,
|
||||
},
|
||||
loading: {
|
||||
icon: Loader2,
|
||||
color: 'text-blue-500',
|
||||
bg: 'bg-blue-500/5',
|
||||
borderColor: 'border-blue-500/20',
|
||||
animate: 'spin' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const { icon, color, bg, borderColor, animate } = config[variant];
|
||||
|
||||
export const ImagePlaceholder = ({
|
||||
width = '100%',
|
||||
height = '100%',
|
||||
animate = 'pulse',
|
||||
aspectRatio
|
||||
}: ImagePlaceholderProps) => {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
w={typeof size === 'string' ? size : undefined}
|
||||
h={typeof size === 'string' ? size : undefined}
|
||||
style={typeof size === 'number' ? { width: size, height: size } : { aspectRatio }}
|
||||
bg={bg}
|
||||
border
|
||||
borderColor={borderColor}
|
||||
rounded={rounded}
|
||||
className={`overflow-hidden ${className}`}
|
||||
gap={2}
|
||||
p={4}
|
||||
<Box
|
||||
width={width}
|
||||
height={height}
|
||||
aspectRatio={aspectRatio}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="var(--ui-color-bg-surface-muted)"
|
||||
style={{ borderRadius: 'var(--ui-radius-md)' }}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
size={6}
|
||||
color={color}
|
||||
animate={animate}
|
||||
icon={ImageIcon}
|
||||
size={8}
|
||||
intent="low"
|
||||
animate={animate === 'spin' ? 'spin' : 'none'}
|
||||
className={animate === 'pulse' ? 'animate-pulse' : ''}
|
||||
/>
|
||||
{message && (
|
||||
<Text
|
||||
size="xs"
|
||||
color={color}
|
||||
weight="medium"
|
||||
align="center"
|
||||
className="max-w-[80%]"
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,77 +1,72 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Icon } from './Icon';
|
||||
import { Info, AlertTriangle, AlertCircle, CheckCircle, LucideIcon } from 'lucide-react';
|
||||
import { Info, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface InfoBannerProps {
|
||||
export interface InfoBannerProps {
|
||||
children?: ReactNode;
|
||||
variant?: 'info' | 'warning' | 'success' | 'critical';
|
||||
type?: 'info' | 'warning' | 'success' | 'critical'; // Alias for variant
|
||||
title?: string;
|
||||
message?: string;
|
||||
children?: React.ReactNode;
|
||||
variant?: 'info' | 'warning' | 'error' | 'success';
|
||||
type?: 'info' | 'warning' | 'error' | 'success';
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export function InfoBanner({ title, message, children, variant = 'info', type, icon }: InfoBannerProps) {
|
||||
const configs = {
|
||||
export const InfoBanner = ({
|
||||
children,
|
||||
variant,
|
||||
type,
|
||||
title
|
||||
}: InfoBannerProps) => {
|
||||
const activeVariant = variant || type || 'info';
|
||||
|
||||
const config = {
|
||||
info: {
|
||||
bg: 'rgba(59, 130, 246, 0.1)',
|
||||
border: 'rgba(59, 130, 246, 0.2)',
|
||||
iconColor: 'rgb(96, 165, 250)',
|
||||
icon: Info
|
||||
icon: Info,
|
||||
intent: 'primary' as const,
|
||||
bg: 'rgba(25, 140, 255, 0.05)',
|
||||
border: 'rgba(25, 140, 255, 0.2)',
|
||||
},
|
||||
warning: {
|
||||
bg: 'rgba(245, 158, 11, 0.1)',
|
||||
border: 'rgba(245, 158, 11, 0.2)',
|
||||
iconColor: 'rgb(251, 191, 36)',
|
||||
icon: AlertTriangle
|
||||
},
|
||||
error: {
|
||||
bg: 'rgba(239, 68, 68, 0.1)',
|
||||
border: 'rgba(239, 68, 68, 0.2)',
|
||||
iconColor: 'rgb(248, 113, 113)',
|
||||
icon: AlertCircle
|
||||
icon: AlertTriangle,
|
||||
intent: 'warning' as const,
|
||||
bg: 'rgba(255, 190, 77, 0.05)',
|
||||
border: 'rgba(255, 190, 77, 0.2)',
|
||||
},
|
||||
success: {
|
||||
bg: 'rgba(16, 185, 129, 0.1)',
|
||||
border: 'rgba(16, 185, 129, 0.2)',
|
||||
iconColor: 'rgb(52, 211, 153)',
|
||||
icon: CheckCircle
|
||||
}
|
||||
};
|
||||
|
||||
const activeVariant = type || variant;
|
||||
const config = configs[activeVariant as keyof typeof configs] || configs.info;
|
||||
const BannerIcon = icon || config.icon;
|
||||
icon: CheckCircle,
|
||||
intent: 'success' as const,
|
||||
bg: 'rgba(111, 227, 122, 0.05)',
|
||||
border: 'rgba(111, 227, 122, 0.2)',
|
||||
},
|
||||
critical: {
|
||||
icon: XCircle,
|
||||
intent: 'critical' as const,
|
||||
bg: 'rgba(227, 92, 92, 0.05)',
|
||||
border: 'rgba(227, 92, 92, 0.2)',
|
||||
},
|
||||
}[activeVariant];
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
border
|
||||
p={4}
|
||||
backgroundColor={config.bg}
|
||||
borderColor={config.border}
|
||||
rounded="lg"
|
||||
padding={4}
|
||||
style={{ backgroundColor: config.bg, border: `1px solid ${config.border}` }}
|
||||
>
|
||||
<Stack direction="row" align="start" gap={3}>
|
||||
<Icon icon={BannerIcon} size={5} color={config.iconColor} />
|
||||
<Box display="flex" alignItems="start" gap={3}>
|
||||
<Icon icon={config.icon} size={5} intent={config.intent} />
|
||||
<Box flex={1}>
|
||||
{title && (
|
||||
<Text weight="medium" color="text-white" block mb={1}>
|
||||
<Text size="sm" weight="bold" variant="high" marginBottom={1} block>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{message && (
|
||||
<Text size="sm" color="text-gray-300" block>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
{children}
|
||||
<Text size="sm" variant="high">
|
||||
{children}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,66 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface InfoBoxProps {
|
||||
export interface InfoBoxProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
variant?: 'info' | 'warning' | 'error' | 'success';
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
variant?: string; // Alias for intent
|
||||
}
|
||||
|
||||
export function InfoBox({ title, description, icon, variant = 'info' }: InfoBoxProps) {
|
||||
const configs = {
|
||||
info: {
|
||||
bg: 'rgba(59, 130, 246, 0.1)',
|
||||
border: 'rgba(59, 130, 246, 0.2)',
|
||||
icon: 'rgb(96, 165, 250)',
|
||||
text: 'text-white'
|
||||
},
|
||||
warning: {
|
||||
bg: 'rgba(245, 158, 11, 0.1)',
|
||||
border: 'rgba(245, 158, 11, 0.2)',
|
||||
icon: 'rgb(251, 191, 36)',
|
||||
text: 'text-white'
|
||||
},
|
||||
error: {
|
||||
bg: 'rgba(239, 68, 68, 0.1)',
|
||||
border: 'rgba(239, 68, 68, 0.2)',
|
||||
icon: 'rgb(248, 113, 113)',
|
||||
text: 'text-white'
|
||||
export const InfoBox = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
intent = 'primary',
|
||||
variant
|
||||
}: InfoBoxProps) => {
|
||||
const activeIntent = (variant || intent) as any;
|
||||
|
||||
const configMap: any = {
|
||||
primary: {
|
||||
bg: 'rgba(25, 140, 255, 0.05)',
|
||||
border: 'rgba(25, 140, 255, 0.2)',
|
||||
},
|
||||
success: {
|
||||
bg: 'rgba(16, 185, 129, 0.1)',
|
||||
border: 'rgba(16, 185, 129, 0.2)',
|
||||
icon: 'rgb(52, 211, 153)',
|
||||
text: 'text-white'
|
||||
}
|
||||
bg: 'rgba(111, 227, 122, 0.05)',
|
||||
border: 'rgba(111, 227, 122, 0.2)',
|
||||
},
|
||||
warning: {
|
||||
bg: 'rgba(255, 190, 77, 0.05)',
|
||||
border: 'rgba(255, 190, 77, 0.2)',
|
||||
},
|
||||
critical: {
|
||||
bg: 'rgba(227, 92, 92, 0.05)',
|
||||
border: 'rgba(227, 92, 92, 0.2)',
|
||||
},
|
||||
telemetry: {
|
||||
bg: 'rgba(78, 212, 224, 0.05)',
|
||||
border: 'rgba(78, 212, 224, 0.2)',
|
||||
},
|
||||
};
|
||||
|
||||
const colors = configs[variant];
|
||||
const config = configMap[activeIntent] || configMap.primary;
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
border
|
||||
p={4}
|
||||
backgroundColor={colors.bg}
|
||||
borderColor={colors.border}
|
||||
padding={4}
|
||||
style={{ backgroundColor: config.bg, border: `1px solid ${config.border}` }}
|
||||
>
|
||||
<Stack direction="row" align="start" gap={3}>
|
||||
<Surface variant="muted" rounded="lg" p={2} bg="bg-white/5">
|
||||
<Icon icon={icon} size={5} color={colors.icon} />
|
||||
<Box display="flex" alignItems="start" gap={4}>
|
||||
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(255,255,255,0.05)' }}>
|
||||
<Icon icon={icon} size={5} intent={activeIntent} />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Text weight="medium" color={colors.text} block>{title}</Text>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>{description}</Text>
|
||||
<Text weight="medium" variant="high" block>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm" variant="low" block marginTop={1}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,33 +4,32 @@ import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface InfoItemProps {
|
||||
icon: LucideIcon;
|
||||
export interface InfoItemProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry' | 'high' | 'med' | 'low';
|
||||
}
|
||||
|
||||
export function InfoItem({ icon, label, value }: InfoItemProps) {
|
||||
export const InfoItem = ({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
intent = 'med'
|
||||
}: InfoItemProps) => {
|
||||
return (
|
||||
<Box display="flex" alignItems="start" gap={2.5}>
|
||||
<Box
|
||||
display="flex"
|
||||
h="7"
|
||||
w="7"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="lg"
|
||||
bg="bg-iron-gray/60"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Icon icon={icon} size={3.5} color="text-gray-500" />
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box padding={2} rounded="lg" bg="var(--ui-color-bg-surface-muted)">
|
||||
<Icon icon={icon} size={4} intent={intent as any} />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text size="xs" color="text-gray-500" block mb={0.5} style={{ fontSize: '10px' }}>{label}</Text>
|
||||
<Text size="xs" weight="medium" color="text-gray-300" block className="truncate">
|
||||
<Box>
|
||||
<Text size="xs" variant="low" block marginBottom={0.5} style={{ fontSize: '10px' }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" weight="medium" variant="high" block className="truncate">
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,59 +1,70 @@
|
||||
import React, { forwardRef, ReactNode } from 'react';
|
||||
import React, { forwardRef, InputHTMLAttributes } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||
label?: string;
|
||||
icon?: ReactNode;
|
||||
errorMessage?: string;
|
||||
variant?: 'default' | 'error';
|
||||
error?: string;
|
||||
hint?: string;
|
||||
fullWidth?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, icon, errorMessage, variant = 'default', className = '', ...props }, ref) => {
|
||||
const isError = variant === 'error' || !!errorMessage;
|
||||
|
||||
const baseClasses = 'w-full px-4 py-2 bg-deep-graphite border rounded-lg text-white placeholder:text-gray-500 focus:outline-none transition-all duration-150 sm:text-sm';
|
||||
const variantClasses = isError
|
||||
? 'border-warning-amber focus:border-warning-amber focus:ring-1 focus:ring-warning-amber'
|
||||
: 'border-charcoal-outline focus:border-primary-blue focus:ring-1 focus:ring-primary-blue';
|
||||
|
||||
const classes = `${baseClasses} ${variantClasses} ${icon ? 'pl-11' : ''} ${className}`;
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
fullWidth = false,
|
||||
size = 'md',
|
||||
...props
|
||||
}, ref) => {
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-4 py-3 text-base'
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={1.5} fullWidth>
|
||||
{label && (
|
||||
<Text as="label" size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
|
||||
const baseClasses = 'bg-[var(--ui-color-bg-surface)] border border-[var(--ui-color-border-default)] text-[var(--ui-color-text-high)] placeholder-[var(--ui-color-text-low)] focus:outline-none focus:border-[var(--ui-color-intent-primary)] transition-colors';
|
||||
const errorClasses = error ? 'border-[var(--ui-color-intent-critical)]' : '';
|
||||
const widthClasses = fullWidth ? 'w-full' : '';
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
sizeClasses[size],
|
||||
errorClasses,
|
||||
widthClasses,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Box width={fullWidth ? '100%' : undefined}>
|
||||
{label && (
|
||||
<Box marginBottom={1.5}>
|
||||
<Text as="label" size="xs" weight="bold" variant="low">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<Box fullWidth position="relative">
|
||||
{icon && (
|
||||
<Box
|
||||
position="absolute"
|
||||
left={0}
|
||||
top="50%"
|
||||
translateY="-50%"
|
||||
zIndex={10}
|
||||
w="11"
|
||||
display="flex"
|
||||
center
|
||||
color="text-gray-500"
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<input ref={ref} className={classes} {...props} />
|
||||
{errorMessage && (
|
||||
<Text size="xs" color="text-warning-amber" mt={1}>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
);
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={classes}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text size="xs" variant="critical">
|
||||
{error}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{hint && !error && (
|
||||
<Box marginTop={1}>
|
||||
<Text size="xs" variant="low">
|
||||
{hint}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
@@ -1,45 +1,38 @@
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Surface } from '@/ui/primitives/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
export function FeatureItem({ text }: { text: string }) {
|
||||
return (
|
||||
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/10" borderColor="border-gray/10" className="group hover:border-primary-accent/20 transition-colors">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box w="0.5" h="3" bg="primary-accent" className="group-hover:h-5 transition-all" />
|
||||
<Text color="text-gray-500" leading="relaxed" weight="normal" size="sm" className="tracking-wide group-hover:text-gray-300 transition-colors">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
export interface LandingItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
}
|
||||
|
||||
export function ResultItem({ text, color }: { text: string, color: string }) {
|
||||
export const LandingItem = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
intent = 'primary'
|
||||
}: LandingItemProps) => {
|
||||
return (
|
||||
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/10" borderColor="border-gray/10" className="group hover:border-primary-accent/20 transition-colors">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box w="0.5" h="3" style={{ backgroundColor: color }} className="group-hover:h-5 transition-all" />
|
||||
<Text color="text-gray-500" leading="relaxed" weight="normal" size="sm" className="tracking-wide group-hover:text-gray-300 transition-colors">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepItem({ step, text }: { step: number, text: string }) {
|
||||
return (
|
||||
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/10" borderColor="border-gray/10" className="group hover:border-primary-accent/20 transition-colors">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box w="6" h="6" display="flex" center border borderColor="border-gray/20" className="group-hover:border-primary-accent/30 transition-colors">
|
||||
<Text weight="bold" size="xs" color="text-primary-accent" font="mono" opacity={0.7}>{step.toString().padStart(2, '0')}</Text>
|
||||
<Surface variant="muted" rounded="xl" padding={6} style={{ border: '1px solid var(--ui-color-border-default)' }}>
|
||||
<Box display="flex" flexDirection="col" gap={4}>
|
||||
<Box padding={3} rounded="lg" bg="var(--ui-color-bg-surface-muted)" width="fit-content">
|
||||
<Icon icon={icon} size={6} intent={intent} />
|
||||
</Box>
|
||||
<Text color="text-gray-500" leading="relaxed" weight="normal" size="sm" className="tracking-wide group-hover:text-gray-300 transition-colors">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box>
|
||||
<Text weight="bold" variant="high" size="lg" marginBottom={2} block>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="low" leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,67 +1,44 @@
|
||||
import { ReactNode } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Grid } from './primitives/Grid';
|
||||
import { Stack } from './primitives/Stack';
|
||||
|
||||
/**
|
||||
* WARNING: DO NOT VIOLATE THE PURPOSE OF THIS COMPONENT.
|
||||
*
|
||||
* Layout is a high-level component for page or section layouts.
|
||||
* It should use Grid or Stack primitives internally.
|
||||
*
|
||||
* If you need a specific layout pattern, create a new component.
|
||||
*/
|
||||
|
||||
interface LayoutProps {
|
||||
export interface LayoutProps {
|
||||
children: ReactNode;
|
||||
padding?: string;
|
||||
gap?: string;
|
||||
grid?: boolean;
|
||||
gridCols?: 1 | 2 | 3 | 4;
|
||||
flex?: boolean;
|
||||
flexCol?: boolean;
|
||||
items?: 'start' | 'center' | 'end' | 'stretch';
|
||||
justify?: 'start' | 'center' | 'end' | 'between' | 'around';
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
sidebar?: ReactNode;
|
||||
}
|
||||
|
||||
export function Layout({
|
||||
export const Layout = ({
|
||||
children,
|
||||
padding = 'p-6',
|
||||
gap = 'gap-4',
|
||||
grid = false,
|
||||
gridCols = 1,
|
||||
flex = false,
|
||||
flexCol = false,
|
||||
items = 'start',
|
||||
justify = 'start'
|
||||
}: LayoutProps) {
|
||||
if (grid) {
|
||||
return (
|
||||
<Grid
|
||||
cols={gridCols as any}
|
||||
className={`${padding} ${gap}`}
|
||||
>
|
||||
{children}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
if (flex) {
|
||||
return (
|
||||
<Stack
|
||||
direction={flexCol ? 'col' : 'row'}
|
||||
align={items}
|
||||
justify={justify}
|
||||
className={`${padding} ${gap}`}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
header,
|
||||
footer,
|
||||
sidebar
|
||||
}: LayoutProps) => {
|
||||
return (
|
||||
<Box className={`${padding} ${gap}`}>
|
||||
{children}
|
||||
<Box display="flex" flexDirection="col" minHeight="100vh" bg="var(--ui-color-bg-base)">
|
||||
{header && (
|
||||
<Box as="header" position="sticky" top="0" zIndex={50}>
|
||||
{header}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box display="flex" flex={1}>
|
||||
{sidebar && (
|
||||
<Box as="aside" width="16rem" display={{ base: 'none', lg: 'block' }}>
|
||||
{sidebar}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box as="main" flex={1}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{footer && (
|
||||
<Box as="footer">
|
||||
{footer}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface LeaderboardListProps {
|
||||
export interface LeaderboardListProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function LeaderboardList({ children }: LeaderboardListProps) {
|
||||
export const LeaderboardList = ({ children }: LeaderboardListProps) => {
|
||||
return (
|
||||
<Box rounded="xl" bg="bg-iron-gray/30" border={true} borderColor="border-charcoal-outline" overflow="hidden">
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
<Surface variant="muted" rounded="xl" style={{ border: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}>
|
||||
<Box display="flex" flexDirection="col">
|
||||
{children}
|
||||
</div>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
};
|
||||
|
||||
export const LeaderboardListItem = ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => {
|
||||
return (
|
||||
<Box
|
||||
padding={4}
|
||||
borderBottom
|
||||
onClick={onClick}
|
||||
style={{ cursor: onClick ? 'pointer' : 'default' }}
|
||||
className={onClick ? 'hover:bg-white/5 transition-colors' : ''}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,70 +1,58 @@
|
||||
import { Award, ChevronRight, LucideIcon } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Button } from './Button';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface LeaderboardPreviewShellProps {
|
||||
export interface LeaderboardPreviewShellProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onViewFull: () => void;
|
||||
subtitle?: string;
|
||||
icon: LucideIcon;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
children: ReactNode;
|
||||
icon?: LucideIcon;
|
||||
iconColor?: string;
|
||||
iconBgGradient?: string;
|
||||
viewFullLabel?: string;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function LeaderboardPreviewShell({
|
||||
title,
|
||||
subtitle,
|
||||
onViewFull,
|
||||
export const LeaderboardPreviewShell = ({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
intent = 'primary',
|
||||
children,
|
||||
icon = Award,
|
||||
iconColor = "#facc15",
|
||||
iconBgGradient = 'linear-gradient(to bottom right, rgba(250, 204, 21, 0.2), rgba(217, 119, 6, 0.1))',
|
||||
viewFullLabel = "View Full Leaderboard",
|
||||
}: LeaderboardPreviewShellProps) {
|
||||
footer
|
||||
}: LeaderboardPreviewShellProps) => {
|
||||
return (
|
||||
<Box mb={12}>
|
||||
{/* Header */}
|
||||
<Stack direction="row" align="center" justify="between" mb={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box
|
||||
display="flex"
|
||||
h="11"
|
||||
w="11"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="xl"
|
||||
style={{ background: iconBgGradient, border: `1px solid ${iconColor}4D` }}
|
||||
>
|
||||
<Icon icon={icon} size={5} color={iconColor} />
|
||||
<Surface variant="default" rounded="xl" style={{ border: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}>
|
||||
<Box padding={6} borderBottom>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" marginBottom={4}>
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box padding={2} rounded="lg" bg="var(--ui-color-bg-surface-muted)">
|
||||
<Icon icon={icon} size={5} intent={intent} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="lg" weight="bold" variant="high" block>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text size="sm" variant="low">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={2}>{title}</Heading>
|
||||
<Text size="sm" color="text-gray-500">{subtitle}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onViewFull}
|
||||
icon={<Icon icon={ChevronRight} size={4} />}
|
||||
>
|
||||
{viewFullLabel}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Compact Leaderboard */}
|
||||
<Box rounded="xl" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline/80" overflow="hidden">
|
||||
<Stack gap={0}>
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{footer && (
|
||||
<Box padding={4} borderTop bg="rgba(255,255,255,0.02)">
|
||||
{footer}
|
||||
</Box>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,49 +1,17 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface LeaderboardTableShellProps {
|
||||
columns: {
|
||||
key: string;
|
||||
label: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
width?: string;
|
||||
}[];
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
export interface LeaderboardTableShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function LeaderboardTableShell({ columns, children, className = '' }: LeaderboardTableShellProps) {
|
||||
export const LeaderboardTableShell = ({ children }: LeaderboardTableShellProps) => {
|
||||
return (
|
||||
<Box
|
||||
rounded="xl"
|
||||
bg="bg-iron-gray/30"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
overflow="hidden"
|
||||
className={className}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline/50 bg-graphite-black/50">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-4 py-3 text-[10px] uppercase tracking-widest font-bold text-gray-500 ${
|
||||
col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
style={col.width ? { width: col.width } : {}}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-charcoal-outline/30">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Box>
|
||||
<Surface variant="default" rounded="xl" style={{ border: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}>
|
||||
<Box>
|
||||
{children}
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,86 +1,66 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box, BoxProps } from './primitives/Box';
|
||||
import React, { ReactNode, forwardRef, AnchorHTMLAttributes } from 'react';
|
||||
|
||||
export interface LinkProps extends Omit<BoxProps<'a'>, 'children' | 'onClick'> {
|
||||
href: string;
|
||||
export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
children: ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
target?: '_blank' | '_self' | '_parent' | '_top';
|
||||
rel?: string;
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'inherit';
|
||||
underline?: 'always' | 'hover' | 'none';
|
||||
size?: string;
|
||||
weight?: string;
|
||||
letterSpacing?: string;
|
||||
block?: boolean;
|
||||
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold' | string;
|
||||
truncate?: boolean;
|
||||
hoverColor?: string;
|
||||
transition?: boolean;
|
||||
}
|
||||
|
||||
export function Link({
|
||||
href,
|
||||
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(({
|
||||
children,
|
||||
className = '',
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
target = '_self',
|
||||
rel = '',
|
||||
onClick,
|
||||
block = false,
|
||||
underline = 'hover',
|
||||
size,
|
||||
weight,
|
||||
truncate,
|
||||
letterSpacing,
|
||||
block = false,
|
||||
hoverColor,
|
||||
transition,
|
||||
transition = true,
|
||||
...props
|
||||
}: LinkProps) {
|
||||
const baseClasses = 'inline-flex items-center transition-colors';
|
||||
|
||||
}, ref) => {
|
||||
const variantClasses = {
|
||||
primary: 'text-primary-accent hover:text-primary-accent/80',
|
||||
secondary: 'text-telemetry-aqua hover:text-telemetry-aqua/80',
|
||||
ghost: 'text-gray-400 hover:text-gray-300'
|
||||
primary: 'text-[var(--ui-color-intent-primary)] hover:opacity-80',
|
||||
secondary: 'text-[var(--ui-color-text-med)] hover:text-[var(--ui-color-text-high)]',
|
||||
ghost: 'text-[var(--ui-color-text-low)] hover:text-[var(--ui-color-text-high)]',
|
||||
inherit: 'text-inherit',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg'
|
||||
const underlineClasses = {
|
||||
always: 'underline',
|
||||
hover: 'hover:underline',
|
||||
none: 'no-underline',
|
||||
};
|
||||
|
||||
const weightClasses: Record<string, string> = {
|
||||
light: 'font-light',
|
||||
normal: 'font-normal',
|
||||
medium: 'font-medium',
|
||||
semibold: 'font-semibold',
|
||||
bold: 'font-bold'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
block ? 'flex' : baseClasses,
|
||||
transition ? 'transition-all duration-150 ease-in-out' : '',
|
||||
'cursor-pointer',
|
||||
block ? 'block' : 'inline',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
weight && weightClasses[weight] ? weightClasses[weight] : '',
|
||||
truncate ? 'truncate' : '',
|
||||
hoverColor ? `hover:${hoverColor}` : '',
|
||||
transition ? 'transition-all duration-150' : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
underlineClasses[underline],
|
||||
].join(' ');
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...(size ? { fontSize: size } : {}),
|
||||
...(weight ? { fontWeight: weight } : {}),
|
||||
...(letterSpacing ? { letterSpacing } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="a"
|
||||
href={href}
|
||||
<a
|
||||
ref={ref}
|
||||
className={classes}
|
||||
target={target}
|
||||
rel={rel}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
...(weight && !weightClasses[weight] ? { fontWeight: weight } : {}),
|
||||
...(props.style || {})
|
||||
}}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Link.displayName = 'Link';
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
export interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg' | number;
|
||||
intent?: 'primary' | 'high' | 'low';
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size = 8, color = '#3b82f6', className = '' }: LoadingSpinnerProps) {
|
||||
export const LoadingSpinner = ({
|
||||
size = 'md',
|
||||
intent = 'primary'
|
||||
}: LoadingSpinnerProps) => {
|
||||
const sizeMap = {
|
||||
sm: '1rem',
|
||||
md: '2rem',
|
||||
lg: '3rem',
|
||||
};
|
||||
|
||||
const dimension = typeof size === 'string' ? sizeMap[size] : `${size * 0.25}rem`;
|
||||
|
||||
const intentColorMap = {
|
||||
primary: 'var(--ui-color-intent-primary)',
|
||||
high: 'var(--ui-color-text-high)',
|
||||
low: 'var(--ui-color-text-low)',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
w={`${size * 0.25}rem`}
|
||||
h={`${size * 0.25}rem`}
|
||||
rounded="full"
|
||||
borderWidth="2px"
|
||||
borderStyle="solid"
|
||||
borderColor="transparent"
|
||||
borderTopColor={color}
|
||||
borderLeftColor={color}
|
||||
className={`animate-spin ${className}`}
|
||||
width={dimension}
|
||||
height={dimension}
|
||||
style={{
|
||||
border: '2px solid rgba(255, 255, 255, 0.1)',
|
||||
borderTop: `2px solid ${intentColorMap[intent]}`,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
className="animate-spin"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
interface MainContentProps {
|
||||
children: React.ReactNode;
|
||||
export interface MainContentProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function MainContent({ children }: MainContentProps) {
|
||||
return <div className="pt-16 md:pt-20">{children}</div>;
|
||||
}
|
||||
export const MainContent = ({ children }: MainContentProps) => {
|
||||
return (
|
||||
<Box as="main" flex={1} display="flex" flexDirection="col" minHeight="0">
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,64 +1,60 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Card } from './Card';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface MetricCardProps {
|
||||
export interface MetricCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon?: LucideIcon;
|
||||
color?: string;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
border?: boolean;
|
||||
bg?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A semantic component for displaying metrics.
|
||||
* Instrument-grade typography and dense-but-readable hierarchy.
|
||||
*/
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
color = 'text-primary-accent',
|
||||
trend,
|
||||
border = true,
|
||||
bg = 'panel-gray/40',
|
||||
}: MetricCardProps) {
|
||||
export const MetricCard = ({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
intent = 'primary',
|
||||
trend
|
||||
}: MetricCardProps) => {
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
rounded="none"
|
||||
p={4}
|
||||
border={border}
|
||||
borderColor="border-gray/30"
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
gap={2}
|
||||
hoverBg="panel-gray/60"
|
||||
transition
|
||||
>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{icon && <Icon icon={icon} size={4} className={color} />}
|
||||
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest">
|
||||
<Card variant="default">
|
||||
<Box display="flex" alignItems="start" justifyContent="between" marginBottom={4}>
|
||||
<Box>
|
||||
<Text size="xs" weight="bold" variant="low" uppercase>
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
{trend && (
|
||||
<Text size="xs" color={trend.isPositive ? 'text-success-green' : 'text-red-400'} font="mono">
|
||||
{trend.isPositive ? '▲' : '▼'} {trend.value}%
|
||||
<Text size="2xl" weight="bold" variant="high" block marginTop={1}>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
{icon && (
|
||||
<Box padding={2} rounded="lg" bg="var(--ui-color-bg-surface-muted)">
|
||||
<Icon icon={icon} size={5} intent={intent} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Text size="2xl" weight="bold" color="text-white" font="mono">
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{trend && (
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Text
|
||||
size="xs"
|
||||
weight="bold"
|
||||
variant={trend.isPositive ? 'success' : 'critical'}
|
||||
>
|
||||
{trend.isPositive ? '+' : '-'}{Math.abs(trend.value)}%
|
||||
</Text>
|
||||
<Text size="xs" variant="low">
|
||||
vs last period
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,17 +2,21 @@ import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface MiniStatProps {
|
||||
export interface MiniStatProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
color?: string;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'high' | 'med' | 'low';
|
||||
}
|
||||
|
||||
export function MiniStat({ label, value, color = 'text-white' }: MiniStatProps) {
|
||||
export const MiniStat = ({
|
||||
label,
|
||||
value,
|
||||
intent = 'high'
|
||||
}: MiniStatProps) => {
|
||||
return (
|
||||
<Box textAlign="center" p={2} rounded="lg" bg="bg-charcoal-outline/30">
|
||||
<Text size="lg" weight="bold" color={color} block>{value}</Text>
|
||||
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>{label}</Text>
|
||||
<Box textAlign="center" padding={2} rounded="lg" bg="var(--ui-color-bg-surface-muted)">
|
||||
<Text size="lg" weight="bold" variant={intent} block>{value}</Text>
|
||||
<Text size="xs" variant="low" block style={{ fontSize: '10px' }}>{label}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,51 +1,63 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Button } from './Button';
|
||||
import { Text } from './Text';
|
||||
import { X } from 'lucide-react';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { IconButton } from './IconButton';
|
||||
import { Button } from './Button';
|
||||
import { X } from 'lucide-react';
|
||||
import { Heading } from './Heading';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface ModalProps {
|
||||
export interface ModalProps {
|
||||
children: ReactNode;
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
primaryActionLabel?: string;
|
||||
onPrimaryAction?: () => void;
|
||||
secondaryActionLabel?: string;
|
||||
onSecondaryAction?: () => void;
|
||||
isLoading?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
footer?: ReactNode;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
export const Modal = ({
|
||||
children,
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
footer,
|
||||
size = 'md',
|
||||
primaryActionLabel,
|
||||
onPrimaryAction,
|
||||
secondaryActionLabel,
|
||||
onSecondaryAction,
|
||||
isLoading = false,
|
||||
size = 'md',
|
||||
}: ModalProps) {
|
||||
footer,
|
||||
description,
|
||||
icon
|
||||
}: ModalProps) => {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const sizeMap = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
sm: '24rem',
|
||||
md: '32rem',
|
||||
lg: '48rem',
|
||||
xl: '64rem',
|
||||
full: '100%',
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -53,96 +65,82 @@ export function Modal({
|
||||
if (onOpenChange) onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
inset={0}
|
||||
zIndex={60}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="bg-black/60"
|
||||
px={4}
|
||||
className="backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
return createPortal(
|
||||
<Box
|
||||
position="fixed"
|
||||
inset={0}
|
||||
zIndex={100}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding={4}
|
||||
bg="rgba(0, 0, 0, 0.8)"
|
||||
>
|
||||
{/* Backdrop click to close */}
|
||||
<Box position="absolute" inset={0} onClick={handleClose} />
|
||||
|
||||
<Box
|
||||
position="relative"
|
||||
w="full"
|
||||
maxWidth={sizeMap[size]}
|
||||
rounded="2xl"
|
||||
bg="bg-[#0f1115]"
|
||||
border
|
||||
borderColor="border-[#262626]"
|
||||
shadow="2xl"
|
||||
overflow="hidden"
|
||||
tabIndex={-1}
|
||||
<Box
|
||||
position="absolute"
|
||||
inset={0}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
<Surface
|
||||
variant="default"
|
||||
rounded="lg"
|
||||
shadow="xl"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: sizeMap[size],
|
||||
maxHeight: '90vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
border: '1px solid var(--ui-color-border-default)'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box p={6} borderBottom borderColor="border-white/5">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
{icon && <Box>{icon}</Box>}
|
||||
<Box>
|
||||
{title && (
|
||||
<Text size="xl" weight="bold" color="text-white" block>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
<IconButton
|
||||
icon={X}
|
||||
onClick={handleClose}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Close modal"
|
||||
/>
|
||||
</Stack>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
padding={4}
|
||||
borderBottom
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
{icon}
|
||||
<Box>
|
||||
{title && <Heading level={3}>{title}</Heading>}
|
||||
{description && <Box marginTop={1}><Text size="sm" variant="low">{description}</Text></Box>}
|
||||
</Box>
|
||||
</Box>
|
||||
<IconButton icon={X} onClick={handleClose} variant="ghost" title="Close modal" />
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box p={6} overflowY="auto" maxHeight="calc(100vh - 200px)">
|
||||
|
||||
<Box flex={1} overflow="auto" padding={6}>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
{(primaryActionLabel || secondaryActionLabel || footer) && (
|
||||
<Box p={6} borderTop borderColor="border-white/5">
|
||||
{footer || (
|
||||
<Stack direction="row" justify="end" gap={3}>
|
||||
{secondaryActionLabel && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onSecondaryAction || onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{secondaryActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
{primaryActionLabel && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onPrimaryAction}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
{(footer || primaryActionLabel || secondaryActionLabel) && (
|
||||
<Box padding={4} borderTop bg="rgba(255,255,255,0.02)" display="flex" justifyContent="end" gap={3}>
|
||||
{footer}
|
||||
{secondaryActionLabel && (
|
||||
<Button
|
||||
onClick={onSecondaryAction || handleClose}
|
||||
variant="ghost"
|
||||
>
|
||||
{secondaryActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
{primaryActionLabel && (
|
||||
<Button
|
||||
onClick={onPrimaryAction}
|
||||
variant="primary"
|
||||
>
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Surface>
|
||||
</Box>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,129 +1,56 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Heading } from './Heading';
|
||||
import { Button } from './Button';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Heading } from './Heading';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { ModalIcon } from './ModalIcon';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface PageHeroProps {
|
||||
export interface PageHeroProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
backgroundPattern?: React.ReactNode;
|
||||
stats?: Array<{
|
||||
icon?: LucideIcon;
|
||||
value: string | number;
|
||||
label: string;
|
||||
color?: string;
|
||||
animate?: boolean;
|
||||
}>;
|
||||
actions?: Array<{
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary';
|
||||
icon?: LucideIcon;
|
||||
description?: string;
|
||||
}>;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
image?: ReactNode;
|
||||
}
|
||||
|
||||
export const PageHero = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
backgroundPattern,
|
||||
stats,
|
||||
actions,
|
||||
export const PageHero = ({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className = ''
|
||||
}: PageHeroProps) => (
|
||||
<Box
|
||||
as="section"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
rounded="2xl"
|
||||
bg="bg-gradient-to-br from-iron-gray/80 via-deep-graphite to-iron-gray/60"
|
||||
border={true}
|
||||
borderColor="border-charcoal-outline/50"
|
||||
className={className}
|
||||
>
|
||||
{/* Background Pattern */}
|
||||
{backgroundPattern || (
|
||||
<>
|
||||
<Box position="absolute" top="0" right="0" width="96" height="96" bg="bg-primary-blue/5" rounded="full" blur="3xl" />
|
||||
<Box position="absolute" bottom="0" left="0" width="64" height="64" bg="bg-neon-aqua/5" rounded="full" blur="3xl" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box position="relative" maxWidth="7xl" mx="auto" px={8} py={10}>
|
||||
<Box display="flex" flexDirection={{ base: 'col', lg: 'row' }} alignItems={{ lg: 'center' }} justifyContent="between" gap={8}>
|
||||
{/* Main Content */}
|
||||
<Box maxWidth="2xl">
|
||||
{icon && (
|
||||
<Stack direction="row" align="center" gap={3} mb={4}>
|
||||
<ModalIcon icon={icon} />
|
||||
<Heading level={1}>
|
||||
{title}
|
||||
</Heading>
|
||||
</Stack>
|
||||
)}
|
||||
{!icon && (
|
||||
<Heading level={1} mb={4}>
|
||||
{title}
|
||||
</Heading>
|
||||
)}
|
||||
image
|
||||
}: PageHeroProps) => {
|
||||
return (
|
||||
<Surface
|
||||
variant="dark"
|
||||
rounded="xl"
|
||||
padding={8}
|
||||
style={{ position: 'relative', overflow: 'hidden', border: '1px solid var(--ui-color-border-default)' }}
|
||||
>
|
||||
<Box display="flex" flexDirection={{ base: 'col', lg: 'row' }} alignItems="center" gap={8}>
|
||||
<Box flex={1}>
|
||||
<Heading level={1} marginBottom={4}>{title}</Heading>
|
||||
{description && (
|
||||
<Text size="lg" color="text-gray-400" block style={{ lineHeight: 1.625 }}>
|
||||
<Text size="lg" variant="low" marginBottom={6} block>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{stats && stats.length > 0 && (
|
||||
<Box display="flex" flexWrap="wrap" gap={6} mt={6}>
|
||||
{stats.map((stat, index) => (
|
||||
<Stack key={index} direction="row" align="center" gap={2}>
|
||||
{stat.icon ? (
|
||||
<Icon icon={stat.icon} size={4} className={stat.color || 'text-primary-blue'} />
|
||||
) : (
|
||||
<Box width="2" height="2" rounded="full" className={`${stat.color || 'bg-primary-blue'} ${stat.animate ? 'animate-pulse' : ''}`} />
|
||||
)}
|
||||
<Text size="sm" color="text-gray-400">
|
||||
<Text color="text-white" weight="semibold">{stat.value}</Text> {stat.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{/* Actions or Custom Content */}
|
||||
{actions && actions.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
{actions.map((action, index) => (
|
||||
<Stack key={index} gap={2}>
|
||||
<Button
|
||||
variant={action.variant || 'primary'}
|
||||
onClick={action.onClick}
|
||||
icon={action.icon && <Icon icon={action.icon} size={5} />}
|
||||
className="px-6 py-3"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
{action.description && (
|
||||
<Text size="xs" color="text-gray-500" align="center" block>{action.description}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
{image && (
|
||||
<Box flex={1} display="flex" justifyContent="center">
|
||||
{image}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
{/* Decorative elements */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-4rem"
|
||||
right="-4rem"
|
||||
width="16rem"
|
||||
height="16rem"
|
||||
bg="var(--ui-color-intent-primary)"
|
||||
style={{ opacity: 0.05, filter: 'blur(64px)', borderRadius: '9999px' }}
|
||||
/>
|
||||
</Surface>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,88 +1,77 @@
|
||||
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Button } from './Button';
|
||||
import { Icon } from './Icon';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface PaginationProps {
|
||||
export interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
onPageChange,
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const startItem = ((currentPage - 1) * itemsPerPage) + 1;
|
||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
const getPageNumbers = () => {
|
||||
if (totalPages <= 5) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
if (currentPage <= 3) {
|
||||
return [1, 2, 3, 4, 5];
|
||||
}
|
||||
|
||||
if (currentPage >= totalPages - 2) {
|
||||
return [totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
|
||||
}
|
||||
|
||||
return [currentPage - 2, currentPage - 1, currentPage, currentPage + 1, currentPage + 2];
|
||||
};
|
||||
export const Pagination = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange
|
||||
}: PaginationProps) => {
|
||||
const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
|
||||
const visiblePages = pages.filter(page => {
|
||||
if (totalPages <= 7) return true;
|
||||
if (page === 1 || page === totalPages) return true;
|
||||
if (page >= currentPage - 1 && page <= currentPage + 1) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<Box display="flex" alignItems="center" justifyContent="between" pt={4}>
|
||||
<Text size="sm" color="text-gray-500">
|
||||
Showing {startItem}–{endItem} of {totalItems}
|
||||
<Box display="flex" alignItems="center" justifyContent="between" paddingTop={4}>
|
||||
<Text size="sm" variant="low">
|
||||
Page {currentPage} of {totalPages}
|
||||
</Text>
|
||||
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Icon icon={ChevronLeft} size={5} />}
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
icon={<ChevronLeft size={16} />}
|
||||
>
|
||||
<Box as="span" className="sr-only">Previous</Box>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
{getPageNumbers().map(pageNum => (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={currentPage === pageNum ? 'primary' : 'ghost'}
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
className="w-10 h-10 p-0"
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{visiblePages.map((page, index) => {
|
||||
const prevPage = visiblePages[index - 1];
|
||||
const showEllipsis = prevPage && page - prevPage > 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={page}>
|
||||
{showEllipsis && <Text variant="low">...</Text>}
|
||||
<Button
|
||||
variant={page === currentPage ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page)}
|
||||
style={{ minWidth: '2.5rem', padding: 0 }}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Icon icon={ChevronRight} size={5} />}
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
icon={<ChevronRight size={16} />}
|
||||
>
|
||||
<Box as="span" className="sr-only">Next</Box>
|
||||
Next
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,57 +1,42 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Box, BoxProps } from './primitives/Box';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface PanelProps extends Omit<BoxProps<'div'>, 'variant' | 'padding'> {
|
||||
children: ReactNode;
|
||||
export interface PanelProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
variant?: 'default' | 'muted' | 'dark' | 'glass';
|
||||
padding?: 0 | 1 | 2 | 3 | 4 | 6 | 8 | 10 | 12;
|
||||
border?: boolean;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
variant?: 'default' | 'dark' | 'muted';
|
||||
}
|
||||
|
||||
/**
|
||||
* A semantic wrapper for content panels.
|
||||
* Follows the "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function Panel({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
variant = 'default',
|
||||
padding = 6,
|
||||
border = true,
|
||||
...props
|
||||
}: PanelProps) {
|
||||
export const Panel = ({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
variant = 'default'
|
||||
}: PanelProps) => {
|
||||
return (
|
||||
<Surface
|
||||
variant={variant}
|
||||
padding={padding}
|
||||
border={border}
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
gap={4}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{...(props as any)}
|
||||
>
|
||||
<Surface variant={variant} rounded="lg" style={{ border: '1px solid var(--ui-color-border-default)' }}>
|
||||
{(title || description) && (
|
||||
<Box display="flex" flexDirection="col" gap={1} borderBottom borderStyle="solid" borderColor="border-gray/30" pb={4} mb={2}>
|
||||
{title && (
|
||||
<Text as="h3" size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
<Box padding={6} borderBottom>
|
||||
{title && <Heading level={3} marginBottom={1}>{title}</Heading>}
|
||||
{description && <Text size="sm" variant="low">{description}</Text>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box padding={6}>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{footer && (
|
||||
<Box padding={4} borderTop bg="rgba(255,255,255,0.02)">
|
||||
{footer}
|
||||
</Box>
|
||||
)}
|
||||
{children}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,42 +1,35 @@
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { Eye, EyeOff, Lock } from 'lucide-react';
|
||||
import { Input } from './Input';
|
||||
import React, { useState } from 'react';
|
||||
import { Input, InputProps } from './Input';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { IconButton } from './IconButton';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
interface PasswordFieldProps extends ComponentProps<typeof Input> {
|
||||
showPassword?: boolean;
|
||||
onTogglePassword?: () => void;
|
||||
}
|
||||
export interface PasswordFieldProps extends InputProps {}
|
||||
|
||||
export const PasswordField = (props: PasswordFieldProps) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
/**
|
||||
* PasswordField
|
||||
*
|
||||
* A specialized input for passwords with visibility toggling.
|
||||
* Stateless UI component.
|
||||
*/
|
||||
export function PasswordField({ showPassword, onTogglePassword, ...props }: PasswordFieldProps) {
|
||||
return (
|
||||
<Box position="relative" fullWidth>
|
||||
<Box position="relative">
|
||||
<Input
|
||||
{...props}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
icon={<Lock size={16} />}
|
||||
/>
|
||||
{onTogglePassword && (
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={onTogglePassword}
|
||||
position="absolute"
|
||||
right="3"
|
||||
top={props.label ? "34px" : "50%"}
|
||||
style={props.label ? {} : { transform: 'translateY(-50%)' }}
|
||||
zIndex={10}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
position="absolute"
|
||||
right="0.5rem"
|
||||
top="50%"
|
||||
style={{ transform: 'translateY(-50%)' }}
|
||||
zIndex={10}
|
||||
>
|
||||
<IconButton
|
||||
icon={showPassword ? EyeOff : Eye}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={showPassword ? 'Hide password' : 'Show password'}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
|
||||
|
||||
import { User } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Icon } from './Icon';
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
export interface PlaceholderImageProps {
|
||||
size?: number;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
size?: string | number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PlaceholderImage({ size = 48, className = '' }: PlaceholderImageProps) {
|
||||
export const PlaceholderImage = ({
|
||||
width,
|
||||
height,
|
||||
size,
|
||||
className
|
||||
}: PlaceholderImageProps) => {
|
||||
const dimension = size || '100%';
|
||||
return (
|
||||
<Box
|
||||
className={`rounded-full bg-charcoal-outline flex items-center justify-center ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
<Box
|
||||
width={width || dimension}
|
||||
height={height || dimension}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="var(--ui-color-bg-surface-muted)"
|
||||
style={{ borderRadius: 'var(--ui-radius-md)' }}
|
||||
className={className}
|
||||
>
|
||||
<Icon icon={User} size={6} color="#9ca3af" />
|
||||
<Icon icon={User} size={6} intent="low" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,70 +1,80 @@
|
||||
|
||||
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { Avatar } from './Avatar';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
import { Heading } from './Heading';
|
||||
|
||||
interface PodiumProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
export interface PodiumEntry {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
value: string | number;
|
||||
position: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
export function Podium({ title, children }: PodiumProps) {
|
||||
return (
|
||||
<Box bg="bg-iron-gray/50" rounded="2xl" border style={{ borderColor: 'rgba(38, 38, 38, 0.8)' }} p={8} mb={10}>
|
||||
<Box display="flex" justifyContent="center" mb={8}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Trophy} size={6} color="var(--warning-amber)" />
|
||||
<Heading level={2}>{title}</Heading>
|
||||
</Stack>
|
||||
</Box>
|
||||
export interface PodiumProps {
|
||||
entries?: PodiumEntry[];
|
||||
title?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
<Stack direction="row" align="end" justify="center" gap={8}>
|
||||
{children}
|
||||
</Stack>
|
||||
export const Podium = ({ entries = [], title, children }: PodiumProps) => {
|
||||
const sortedEntries = [...entries].sort((a, b) => {
|
||||
const order = { 2: 0, 1: 1, 3: 2 };
|
||||
return order[a.position] - order[b.position];
|
||||
});
|
||||
|
||||
const getPositionColor = (pos: number) => {
|
||||
if (pos === 1) return 'var(--ui-color-intent-warning)';
|
||||
if (pos === 2) return '#A1A1AA';
|
||||
if (pos === 3) return '#CD7F32';
|
||||
return 'var(--ui-color-text-low)';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box paddingY={8}>
|
||||
{title && <Heading level={2} align="center" marginBottom={8}>{title}</Heading>}
|
||||
|
||||
<Box display="flex" alignItems="end" justifyContent="center" gap={4}>
|
||||
{sortedEntries.map((entry) => {
|
||||
const height = entry.position === 1 ? '12rem' : entry.position === 2 ? '10rem' : '8rem';
|
||||
const color = getPositionColor(entry.position);
|
||||
|
||||
return (
|
||||
<Box key={entry.position} display="flex" flexDirection="col" alignItems="center" gap={4}>
|
||||
<Box display="flex" flexDirection="col" alignItems="center" gap={2}>
|
||||
{entry.position === 1 && <Icon icon={Trophy} size={6} intent="warning" />}
|
||||
<Avatar src={entry.avatar} alt={entry.name} size={entry.position === 1 ? 'lg' : 'md'} />
|
||||
<Text weight="bold" variant="high" size={entry.position === 1 ? 'md' : 'sm'}>{entry.name}</Text>
|
||||
<Text size="xs" variant="low">{entry.value}</Text>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
width="6rem"
|
||||
height={height}
|
||||
bg="var(--ui-color-bg-surface-muted)"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
style={{
|
||||
borderTopLeftRadius: 'var(--ui-radius-lg)',
|
||||
borderTopRightRadius: 'var(--ui-radius-lg)',
|
||||
border: `1px solid ${color}`,
|
||||
borderBottom: 'none'
|
||||
}}
|
||||
>
|
||||
<Text size="3xl" weight="bold" style={{ color }}>
|
||||
{entry.position}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface PodiumItemProps {
|
||||
position: number;
|
||||
height: string;
|
||||
cardContent: ReactNode;
|
||||
bgColor: string;
|
||||
positionColor: string;
|
||||
}
|
||||
|
||||
export function PodiumItem({
|
||||
position,
|
||||
height,
|
||||
cardContent,
|
||||
bgColor,
|
||||
positionColor,
|
||||
}: PodiumItemProps) {
|
||||
return (
|
||||
<Stack align="center">
|
||||
{cardContent}
|
||||
|
||||
{/* Podium stand */}
|
||||
<Box
|
||||
bg={bgColor}
|
||||
h={height}
|
||||
border
|
||||
style={{ borderColor: 'rgba(38, 38, 38, 0.8)', borderTopLeftRadius: '0.5rem', borderTopRightRadius: '0.5rem' }}
|
||||
w="28"
|
||||
display="flex"
|
||||
p={3}
|
||||
>
|
||||
<Box display="flex" center fullWidth>
|
||||
<Text size="3xl" weight="bold" color={positionColor}>
|
||||
{position}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
export const PodiumItem = ({ children }: { children: ReactNode }) => <>{children}</>;
|
||||
|
||||
@@ -1,122 +1,56 @@
|
||||
|
||||
|
||||
import type { MouseEventHandler, ReactNode } from 'react';
|
||||
import { Card } from './Card';
|
||||
|
||||
interface PresetCardStat {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface PresetCardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
primaryTag?: string;
|
||||
description?: string;
|
||||
stats?: PresetCardStat[];
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect?: () => void;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
export function PresetCard({
|
||||
title,
|
||||
subtitle,
|
||||
primaryTag,
|
||||
description,
|
||||
stats,
|
||||
selected,
|
||||
disabled,
|
||||
onSelect,
|
||||
className = '',
|
||||
children,
|
||||
}: PresetCardProps) {
|
||||
const isInteractive = typeof onSelect === 'function' && !disabled;
|
||||
|
||||
const handleClick: MouseEventHandler<HTMLButtonElement | HTMLDivElement> = (event) => {
|
||||
if (!isInteractive) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onSelect?.();
|
||||
};
|
||||
|
||||
const baseBorder = selected ? 'border-primary-blue' : 'border-charcoal-outline';
|
||||
const baseBg = selected ? 'bg-primary-blue/10' : 'bg-iron-gray';
|
||||
const baseRing = selected ? 'ring-2 ring-primary-blue/40' : '';
|
||||
const disabledClasses = disabled ? 'opacity-60 cursor-not-allowed' : '';
|
||||
const hoverClasses = isInteractive && !disabled ? 'hover:bg-iron-gray/80 hover:scale-[1.01]' : '';
|
||||
|
||||
const content = (
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{title}</div>
|
||||
{subtitle && (
|
||||
<div className="mt-0.5 text-xs text-gray-400">{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{primaryTag && (
|
||||
<span className="inline-flex rounded-full bg-primary-blue/15 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary-blue">
|
||||
{primaryTag}
|
||||
</span>
|
||||
)}
|
||||
{selected && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary-blue/10 px-2 py-0.5 text-[10px] font-medium text-primary-blue">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary-blue" />
|
||||
Selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className="text-xs text-gray-300">{description}</p>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{stats && stats.length > 0 && (
|
||||
<div className="mt-1 border-t border-charcoal-outline/70 pt-2">
|
||||
<dl className="grid grid-cols-1 gap-2 text-[11px] text-gray-400 sm:grid-cols-3">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="space-y-0.5">
|
||||
<dt className="font-medium text-gray-500">{stat.label}</dt>
|
||||
<dd className="text-xs text-gray-200">{stat.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const commonClasses = `${baseBorder} ${baseBg} ${baseRing} ${hoverClasses} ${disabledClasses} ${className}`;
|
||||
|
||||
if (isInteractive) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick as MouseEventHandler<HTMLButtonElement>}
|
||||
disabled={disabled}
|
||||
className={`group block w-full rounded-lg text-left text-sm shadow-card outline-none transition-all duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-blue ${commonClasses}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
{content}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export const PresetCard = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
onClick,
|
||||
isSelected = false
|
||||
}: PresetCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
className={commonClasses}
|
||||
onClick={handleClick as MouseEventHandler<HTMLDivElement>}
|
||||
<Surface
|
||||
variant={isSelected ? 'default' : 'muted'}
|
||||
rounded="lg"
|
||||
padding={4}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border: isSelected ? '2px solid var(--ui-color-intent-primary)' : '1px solid var(--ui-color-border-default)',
|
||||
transition: 'all 0.2s ease-in-out'
|
||||
}}
|
||||
className="group hover:bg-white/5"
|
||||
>
|
||||
{content}
|
||||
</Card>
|
||||
<Box display="flex" alignItems="start" gap={4}>
|
||||
<Box
|
||||
padding={3}
|
||||
rounded="lg"
|
||||
bg={isSelected ? 'var(--ui-color-intent-primary)' : 'var(--ui-color-bg-surface-muted)'}
|
||||
className="transition-colors"
|
||||
>
|
||||
<Icon icon={icon} size={6} intent={isSelected ? 'high' : 'low'} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text weight="bold" variant="high" block marginBottom={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm" variant="low">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,33 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Box, BoxProps } from './primitives/Box';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
interface ProgressBarProps extends Omit<BoxProps<'div'>, 'children'> {
|
||||
export interface ProgressBarProps {
|
||||
value: number;
|
||||
max: number;
|
||||
color?: string;
|
||||
bg?: string;
|
||||
height?: string;
|
||||
max?: number;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
color?: string; // Alias for intent
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
marginBottom?: any;
|
||||
mb?: any; // Alias for marginBottom
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
value,
|
||||
max,
|
||||
color = 'bg-primary-blue',
|
||||
bg = 'bg-deep-graphite',
|
||||
height = '2',
|
||||
...props
|
||||
}: ProgressBarProps) {
|
||||
const percentage = Math.min((value / max) * 100, 100);
|
||||
export const ProgressBar = ({
|
||||
value,
|
||||
max = 100,
|
||||
intent = 'primary',
|
||||
color: colorProp,
|
||||
size = 'md',
|
||||
marginBottom,
|
||||
mb
|
||||
}: ProgressBarProps) => {
|
||||
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
|
||||
|
||||
const intentColorMap = {
|
||||
primary: 'var(--ui-color-intent-primary)',
|
||||
success: 'var(--ui-color-intent-success)',
|
||||
warning: 'var(--ui-color-intent-warning)',
|
||||
critical: 'var(--ui-color-intent-critical)',
|
||||
telemetry: 'var(--ui-color-intent-telemetry)',
|
||||
};
|
||||
|
||||
const color = colorProp || intentColorMap[intent];
|
||||
|
||||
const sizeMap = {
|
||||
sm: '0.25rem',
|
||||
md: '0.5rem',
|
||||
lg: '1rem',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box fullWidth bg={bg} rounded="full" height={height} overflow="hidden" {...props}>
|
||||
<Box
|
||||
fullWidth
|
||||
bg="var(--ui-color-bg-surface-muted)"
|
||||
style={{ height: sizeMap[size], borderRadius: '9999px', overflow: 'hidden' }}
|
||||
marginBottom={marginBottom || mb}
|
||||
>
|
||||
<Box
|
||||
bg={color}
|
||||
rounded="full"
|
||||
fullHeight
|
||||
style={{ width: `${percentage}%` }}
|
||||
className="transition-all duration-500 ease-out"
|
||||
bg={color}
|
||||
style={{ width: `${percentage}%`, transition: 'width 0.3s ease-in-out' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,51 +1,59 @@
|
||||
|
||||
|
||||
import { ChevronRight, LucideIcon } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Icon } from './Icon';
|
||||
import { Link } from './Link';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon, ChevronRight } from 'lucide-react';
|
||||
import { Link } from './Link';
|
||||
|
||||
interface QuickActionItemProps {
|
||||
href: string;
|
||||
export interface QuickActionItemProps {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
iconVariant?: 'blue' | 'amber' | 'purple' | 'green';
|
||||
href: string;
|
||||
variant?: 'primary' | 'secondary' | 'success' | 'warning' | 'critical';
|
||||
}
|
||||
|
||||
export function QuickActionItem({ href, label, icon, iconVariant = 'blue' }: QuickActionItemProps) {
|
||||
const variantColors = {
|
||||
blue: 'rgb(59, 130, 246)',
|
||||
amber: 'rgb(245, 158, 11)',
|
||||
purple: 'rgb(168, 85, 247)',
|
||||
green: 'rgb(16, 185, 129)',
|
||||
export const QuickActionItem = ({
|
||||
label,
|
||||
icon,
|
||||
href,
|
||||
variant = 'primary'
|
||||
}: QuickActionItemProps) => {
|
||||
const variantBgs = {
|
||||
primary: 'rgba(25, 140, 255, 0.1)',
|
||||
secondary: 'var(--ui-color-bg-surface-muted)',
|
||||
success: 'rgba(111, 227, 122, 0.1)',
|
||||
warning: 'rgba(255, 190, 77, 0.1)',
|
||||
critical: 'rgba(227, 92, 92, 0.1)',
|
||||
};
|
||||
|
||||
const variantBgs = {
|
||||
blue: 'bg-primary-blue/10',
|
||||
amber: 'bg-warning-amber/10',
|
||||
purple: 'bg-purple-500/10',
|
||||
green: 'bg-performance-green/10',
|
||||
const variantIntents = {
|
||||
primary: 'primary' as const,
|
||||
secondary: 'med' as const,
|
||||
success: 'success' as const,
|
||||
warning: 'warning' as const,
|
||||
critical: 'critical' as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
block
|
||||
p={3}
|
||||
rounded="lg"
|
||||
bg="bg-deep-graphite"
|
||||
hoverBorderColor="charcoal-outline/50"
|
||||
transition
|
||||
className="hover:bg-charcoal-outline/50"
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={3} fullWidth>
|
||||
<Box p={2} bg={variantBgs[iconVariant]} rounded="lg">
|
||||
<Icon icon={icon} size={4} color={variantColors[iconVariant]} />
|
||||
<Link href={href} underline="none">
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={4}
|
||||
padding={4}
|
||||
rounded="lg"
|
||||
bg="var(--ui-color-bg-surface)"
|
||||
style={{ border: '1px solid var(--ui-color-border-default)' }}
|
||||
className="group hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Box padding={2} bg={variantBgs[variant]} rounded="lg">
|
||||
<Icon icon={icon} size={5} intent={variantIntents[variant] as any} />
|
||||
</Box>
|
||||
<Text size="sm" color="text-white" weight="medium">{label}</Text>
|
||||
<Icon icon={ChevronRight} size={4} color="rgb(107, 114, 128)" ml="auto" />
|
||||
<Text weight="medium" variant="high" flexGrow={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<Icon icon={ChevronRight} size={4} intent="low" />
|
||||
</Box>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon, ArrowRight } from 'lucide-react';
|
||||
import { Link } from './Link';
|
||||
|
||||
interface QuickActionLinkProps {
|
||||
export interface QuickActionLinkProps {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
variant?: 'blue' | 'purple' | 'orange';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function QuickActionLink({
|
||||
href,
|
||||
children,
|
||||
variant = 'blue',
|
||||
className = ''
|
||||
}: QuickActionLinkProps) {
|
||||
const variantClasses = {
|
||||
blue: 'bg-primary-blue/20 border-primary-blue/30 text-primary-blue hover:bg-primary-blue/30',
|
||||
purple: 'bg-purple-500/20 border-purple-500/30 text-purple-300 hover:bg-purple-500/30',
|
||||
orange: 'bg-orange-500/20 border-orange-500/30 text-orange-300 hover:bg-orange-500/30'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
'px-4 py-3 border rounded-lg transition-colors text-sm font-medium text-center inline-block w-full',
|
||||
variantClasses[variant],
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
export const QuickActionLink = ({
|
||||
label,
|
||||
icon,
|
||||
href
|
||||
}: QuickActionLinkProps) => {
|
||||
return (
|
||||
<a href={href} className={classes}>
|
||||
{children}
|
||||
</a>
|
||||
<Link href={href} underline="none">
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={3}
|
||||
paddingY={2}
|
||||
className="group"
|
||||
>
|
||||
<Icon icon={icon} size={4} intent="low" className="group-hover:text-[var(--ui-color-intent-primary)] transition-colors" />
|
||||
<Text size="sm" variant="med" className="group-hover:text-[var(--ui-color-text-high)] transition-colors">
|
||||
{label}
|
||||
</Text>
|
||||
<Icon icon={ArrowRight} size={3} intent="low" className="opacity-0 group-hover:opacity-100 group-hover:translate-x-1 transition-all" />
|
||||
</Box>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,42 +5,37 @@ import { Button } from './Button';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface QuickAction {
|
||||
export interface QuickAction {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
intent?: 'primary' | 'secondary' | 'danger';
|
||||
}
|
||||
|
||||
interface QuickActionsPanelProps {
|
||||
export interface QuickActionsPanelProps {
|
||||
actions: QuickAction[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* QuickActionsPanel
|
||||
*
|
||||
* Provides fast access to common dashboard tasks.
|
||||
*/
|
||||
export function QuickActionsPanel({ actions, className = '' }: QuickActionsPanelProps) {
|
||||
export const QuickActionsPanel = ({
|
||||
actions
|
||||
}: QuickActionsPanelProps) => {
|
||||
return (
|
||||
<Panel title="Quick Actions" className={className}>
|
||||
<Box display="grid" gridCols={1} gap={2}>
|
||||
<Panel title="Quick Actions">
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
{actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'secondary'}
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.intent === 'danger' ? 'danger' : 'secondary'}
|
||||
onClick={action.onClick}
|
||||
fullWidth
|
||||
style={{ justifyContent: 'flex-start', height: 'auto', padding: '12px' }}
|
||||
>
|
||||
<Box display="flex" align="center" gap={3}>
|
||||
<Icon icon={action.icon} size={5} />
|
||||
<span>{action.label}</span>
|
||||
<Box display="flex" alignItems="center" gap={3} fullWidth>
|
||||
<Icon icon={action.icon} size={4} />
|
||||
{action.label}
|
||||
</Box>
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,74 +1,42 @@
|
||||
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface SectionProps {
|
||||
export interface SectionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
variant?: 'default' | 'card' | 'highlight' | 'dark' | 'light';
|
||||
variant?: 'default' | 'dark' | 'muted';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
id?: string;
|
||||
py?: number;
|
||||
minHeight?: string;
|
||||
borderBottom?: boolean;
|
||||
borderColor?: string;
|
||||
overflow?: 'hidden' | 'visible' | 'auto' | 'scroll';
|
||||
position?: 'relative' | 'absolute' | 'fixed' | 'sticky';
|
||||
}
|
||||
|
||||
export function Section({
|
||||
export const Section = ({
|
||||
children,
|
||||
className = '',
|
||||
title,
|
||||
description,
|
||||
variant = 'default',
|
||||
id,
|
||||
py = 16,
|
||||
minHeight,
|
||||
borderBottom,
|
||||
borderColor,
|
||||
overflow,
|
||||
position
|
||||
}: SectionProps) {
|
||||
padding = 'md',
|
||||
id
|
||||
}: SectionProps) => {
|
||||
const variantClasses = {
|
||||
default: '',
|
||||
card: 'bg-panel-gray rounded-none p-6 border border-border-gray',
|
||||
highlight: 'bg-gradient-to-r from-primary-accent/10 to-transparent rounded-none p-6 border border-primary-accent/30',
|
||||
dark: 'bg-graphite-black',
|
||||
light: 'bg-panel-gray'
|
||||
default: 'bg-[var(--ui-color-bg-base)]',
|
||||
dark: 'bg-black',
|
||||
muted: 'bg-[var(--ui-color-bg-surface)]',
|
||||
};
|
||||
|
||||
|
||||
const paddingClasses = {
|
||||
none: 'py-0',
|
||||
sm: 'py-8',
|
||||
md: 'py-16',
|
||||
lg: 'py-24',
|
||||
};
|
||||
|
||||
const classes = [
|
||||
variantClasses[variant],
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
paddingClasses[padding],
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="section"
|
||||
id={id}
|
||||
className={classes}
|
||||
py={py as 0}
|
||||
px={4}
|
||||
minHeight={minHeight}
|
||||
borderBottom={borderBottom}
|
||||
borderColor={borderColor}
|
||||
overflow={overflow}
|
||||
position={position}
|
||||
>
|
||||
<Box className="mx-auto max-w-7xl">
|
||||
{(title || description) && (
|
||||
<Box mb={8}>
|
||||
{title && <Heading level={2}>{title}</Heading>}
|
||||
{description && <Text color="text-gray-400" block mt={2}>{description}</Text>}
|
||||
</Box>
|
||||
)}
|
||||
<section id={id} className={classes}>
|
||||
<Box marginX="auto" maxWidth="80rem" paddingX={4}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,51 +1,55 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface SectionHeaderProps {
|
||||
export interface SectionHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
color?: string;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function SectionHeader({ title, description, icon, color = 'text-primary-blue', actions }: SectionHeaderProps) {
|
||||
export const SectionHeader = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
intent = 'primary',
|
||||
actions
|
||||
}: SectionHeaderProps) => {
|
||||
return (
|
||||
<Box
|
||||
p={5}
|
||||
padding={5}
|
||||
borderBottom
|
||||
borderColor="border-white/5"
|
||||
style={{ background: 'linear-gradient(to right, rgba(38, 38, 38, 0.3), transparent)' }}
|
||||
style={{ background: 'linear-gradient(to right, var(--ui-color-bg-surface), transparent)' }}
|
||||
>
|
||||
<Stack direction="row" align="center" justify="between" wrap gap={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
{icon && (
|
||||
<Surface variant="muted" rounded="lg" p={2} bg="bg-white/5">
|
||||
<Icon icon={icon} size={5} color={color} />
|
||||
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(255,255,255,0.05)' }}>
|
||||
<Icon icon={icon} size={5} intent={intent} />
|
||||
</Surface>
|
||||
)}
|
||||
<Box>
|
||||
<Text size="lg" weight="bold" color="text-white" block>
|
||||
<Text size="lg" weight="bold" variant="high" block>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="sm" color="text-gray-400" block>
|
||||
<Text size="sm" variant="low" block>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
{actions && (
|
||||
<Box>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,82 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface SegmentedControlOption {
|
||||
value: string;
|
||||
export interface SegmentedControlOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps {
|
||||
export interface SegmentedControlProps {
|
||||
options: SegmentedControlOption[];
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
activeId: string;
|
||||
onChange: (id: string) => void;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export function SegmentedControl({
|
||||
options,
|
||||
value,
|
||||
export const SegmentedControl = ({
|
||||
options,
|
||||
activeId,
|
||||
onChange,
|
||||
}: SegmentedControlProps) {
|
||||
const handleSelect = (optionValue: string, optionDisabled?: boolean) => {
|
||||
if (!onChange || optionDisabled) return;
|
||||
if (optionValue === value) return;
|
||||
onChange(optionValue);
|
||||
};
|
||||
|
||||
fullWidth = false
|
||||
}: SegmentedControlProps) => {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
padding={1}
|
||||
display="inline-flex"
|
||||
w="full"
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
rounded="full"
|
||||
bg="bg-black/60"
|
||||
p={1}
|
||||
width={fullWidth ? '100%' : undefined}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isSelected = option.value === value;
|
||||
|
||||
const isSelected = option.id === activeId;
|
||||
return (
|
||||
<Box
|
||||
key={option.value}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.value, option.disabled)}
|
||||
aria-pressed={isSelected}
|
||||
disabled={option.disabled}
|
||||
flex={1}
|
||||
minWidth="140px"
|
||||
px={3}
|
||||
py={1.5}
|
||||
rounded="full"
|
||||
transition="all 0.2s"
|
||||
textAlign="left"
|
||||
bg={isSelected ? 'bg-primary-blue' : 'transparent'}
|
||||
color={isSelected ? 'text-white' : 'text-gray-400'}
|
||||
opacity={option.disabled ? 0.5 : 1}
|
||||
cursor={option.disabled ? 'not-allowed' : 'pointer'}
|
||||
border="none"
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => onChange(option.id)}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-1.5 text-xs font-bold uppercase tracking-widest transition-all rounded-md ${
|
||||
isSelected
|
||||
? 'bg-[var(--ui-color-bg-surface)] text-[var(--ui-color-intent-primary)] shadow-sm'
|
||||
: 'text-[var(--ui-color-text-low)] hover:text-[var(--ui-color-text-high)]'
|
||||
}`}
|
||||
>
|
||||
<Stack gap={0.5}>
|
||||
<Text size="xs" weight="medium" color="inherit">{option.label}</Text>
|
||||
{option.description && (
|
||||
<Text
|
||||
size="xs"
|
||||
color={isSelected ? 'text-white' : 'text-gray-400'}
|
||||
fontSize="10px"
|
||||
opacity={isSelected ? 0.8 : 1}
|
||||
>
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,64 +1,90 @@
|
||||
import React, { forwardRef, ReactNode } from 'react';
|
||||
import React, { forwardRef, SelectHTMLAttributes } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
|
||||
label?: string;
|
||||
options: SelectOption[];
|
||||
error?: string;
|
||||
hint?: string;
|
||||
fullWidth?: boolean;
|
||||
pl?: number;
|
||||
errorMessage?: string;
|
||||
variant?: 'default' | 'error';
|
||||
options?: SelectOption[];
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ label, fullWidth = true, pl, errorMessage, variant = 'default', options, children, className = '', style, ...props }, ref) => {
|
||||
const isError = variant === 'error' || !!errorMessage;
|
||||
|
||||
const variantClasses = isError
|
||||
? 'border-warning-amber focus:border-warning-amber'
|
||||
: 'border-charcoal-outline focus:border-primary-blue';
|
||||
|
||||
const defaultClasses = `${fullWidth ? 'w-full' : 'w-auto'} px-3 py-2 bg-deep-graphite border rounded-lg text-white focus:outline-none transition-colors`;
|
||||
const classes = [
|
||||
defaultClasses,
|
||||
variantClasses,
|
||||
pl ? `pl-${pl}` : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(({
|
||||
label,
|
||||
options,
|
||||
error,
|
||||
hint,
|
||||
fullWidth = false,
|
||||
size = 'md',
|
||||
...props
|
||||
}, ref) => {
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-4 py-3 text-base'
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={1.5} fullWidth={fullWidth}>
|
||||
{label && (
|
||||
<Text as="label" size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
|
||||
const baseClasses = 'bg-[var(--ui-color-bg-surface)] border border-[var(--ui-color-border-default)] text-[var(--ui-color-text-high)] focus:outline-none focus:border-[var(--ui-color-intent-primary)] transition-colors appearance-none';
|
||||
const errorClasses = error ? 'border-[var(--ui-color-intent-critical)]' : '';
|
||||
const widthClasses = fullWidth ? 'w-full' : '';
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
sizeClasses[size],
|
||||
errorClasses,
|
||||
widthClasses,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Box width={fullWidth ? '100%' : undefined}>
|
||||
{label && (
|
||||
<Box marginBottom={1.5}>
|
||||
<Text as="label" size="xs" weight="bold" variant="low">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<Box
|
||||
as="select"
|
||||
ref={ref}
|
||||
className={classes}
|
||||
style={style}
|
||||
</Box>
|
||||
)}
|
||||
<div className="relative">
|
||||
<select
|
||||
ref={ref}
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
{options ? options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
)) : children}
|
||||
</Box>
|
||||
{errorMessage && (
|
||||
<Text size="xs" color="text-warning-amber" mt={1}>
|
||||
{errorMessage}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-[var(--ui-color-text-low)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text size="xs" variant="critical">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
);
|
||||
</Box>
|
||||
)}
|
||||
{hint && !error && (
|
||||
<Box marginTop={1}>
|
||||
<Text size="xs" variant="low">
|
||||
{hint}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
@@ -1,37 +1,66 @@
|
||||
import React from 'react';
|
||||
import { ChevronRight, LucideIcon } from 'lucide-react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon, ChevronRight } from 'lucide-react';
|
||||
import { Link } from './Link';
|
||||
|
||||
interface SidebarActionLinkProps {
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
export interface SidebarActionLinkProps {
|
||||
label: string;
|
||||
iconColor?: string;
|
||||
iconBgColor?: string;
|
||||
icon: LucideIcon;
|
||||
href: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarActionLink({
|
||||
export const SidebarActionLink = ({
|
||||
label,
|
||||
icon,
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
iconColor = 'text-primary-blue',
|
||||
iconBgColor = 'bg-primary-blue/10',
|
||||
}: SidebarActionLinkProps) {
|
||||
isActive = false
|
||||
}: SidebarActionLinkProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
variant="ghost"
|
||||
block
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite hover:bg-iron-gray/50 transition-all"
|
||||
<Link
|
||||
href={href}
|
||||
variant="ghost"
|
||||
underline="none"
|
||||
>
|
||||
<Box p={2} className={iconBgColor} rounded="lg" display="flex" center>
|
||||
<Icon icon={icon} size={4} className={iconColor} />
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={3}
|
||||
padding={3}
|
||||
rounded="lg"
|
||||
bg={isActive ? 'var(--ui-color-bg-surface-muted)' : 'transparent'}
|
||||
className="group hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Box
|
||||
padding={2}
|
||||
rounded="md"
|
||||
bg={isActive ? 'var(--ui-color-intent-primary)' : 'var(--ui-color-bg-surface-muted)'}
|
||||
display="flex"
|
||||
center
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
size={4}
|
||||
intent={isActive ? 'high' : 'low'}
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
size="sm"
|
||||
variant={isActive ? 'high' : 'med'}
|
||||
weight={isActive ? 'bold' : 'medium'}
|
||||
flexGrow={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Icon
|
||||
icon={ChevronRight}
|
||||
size={4}
|
||||
intent="low"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</Box>
|
||||
<Text size="sm" color="text-white" flexGrow={1}>{label}</Text>
|
||||
<Icon icon={ChevronRight} size={4} color="text-gray-500" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
import React from 'react';
|
||||
import React, { forwardRef, ChangeEvent } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
interface CheckboxProps {
|
||||
export interface SimpleCheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SimpleCheckbox
|
||||
*
|
||||
* A checkbox without a label for use in tables.
|
||||
*/
|
||||
export function SimpleCheckbox({ checked, onChange, disabled, 'aria-label': ariaLabel }: CheckboxProps) {
|
||||
export const SimpleCheckbox = forwardRef<HTMLInputElement, SimpleCheckboxProps>(({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false
|
||||
}, ref) => {
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="input"
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.checked)}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
w="4"
|
||||
h="4"
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="sm"
|
||||
aria-label={ariaLabel}
|
||||
ring="primary-blue"
|
||||
color="text-primary-blue"
|
||||
className="w-4 h-4 rounded-none border-[var(--ui-color-border-default)] bg-[var(--ui-color-bg-surface)] text-[var(--ui-color-intent-primary)] focus:ring-[var(--ui-color-intent-primary)] cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
SimpleCheckbox.displayName = 'SimpleCheckbox';
|
||||
|
||||
@@ -1,23 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
interface SkeletonProps {
|
||||
export interface SkeletonProps {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
circle?: boolean;
|
||||
className?: string;
|
||||
variant?: 'text' | 'circular' | 'rectangular';
|
||||
animation?: 'pulse' | 'wave' | 'none';
|
||||
}
|
||||
|
||||
export function Skeleton({ width, height, circle, className = '' }: SkeletonProps) {
|
||||
export const Skeleton = ({
|
||||
width,
|
||||
height,
|
||||
variant = 'rectangular',
|
||||
animation = 'pulse'
|
||||
}: SkeletonProps) => {
|
||||
const variantClasses = {
|
||||
text: 'rounded-sm',
|
||||
circular: 'rounded-full',
|
||||
rectangular: 'rounded-none',
|
||||
};
|
||||
|
||||
const animationClasses = {
|
||||
pulse: 'animate-pulse',
|
||||
wave: 'animate-shimmer', // Assuming shimmer is defined
|
||||
none: '',
|
||||
};
|
||||
|
||||
const classes = [
|
||||
'bg-[var(--ui-color-bg-surface-muted)]',
|
||||
variantClasses[variant],
|
||||
animationClasses[animation],
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<Box
|
||||
w={width}
|
||||
h={height}
|
||||
rounded={circle ? 'full' : 'md'}
|
||||
bg="bg-white/5"
|
||||
className={`animate-pulse ${className}`}
|
||||
width={width}
|
||||
height={height}
|
||||
className={classes}
|
||||
role="status"
|
||||
aria-label="Loading..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface StatBoxProps {
|
||||
icon: LucideIcon;
|
||||
export interface StatBoxProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
color?: string;
|
||||
icon: LucideIcon;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
}
|
||||
|
||||
export function StatBox({ icon, label, value, color = 'text-primary-blue' }: StatBoxProps) {
|
||||
export const StatBox = ({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
intent = 'primary'
|
||||
}: StatBoxProps) => {
|
||||
return (
|
||||
<Surface variant="muted" rounded="xl" border padding={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Surface variant="muted" rounded="lg" padding={2}>
|
||||
<Icon icon={icon} size={5} color={color} />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Text size="2xl" weight="bold" color="text-white" block>{value}</Text>
|
||||
<Text size="xs" color="text-gray-500" block>{label}</Text>
|
||||
<Surface variant="muted" rounded="xl" padding={4} style={{ border: '1px solid var(--ui-color-border-default)' }}>
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box padding={2} rounded="lg" bg="var(--ui-color-bg-surface-muted)">
|
||||
<Icon icon={icon} size={5} intent={intent} />
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box>
|
||||
<Text size="xs" weight="bold" variant="low" uppercase>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xl" weight="bold" variant="high" block marginTop={0.5}>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,115 +1,68 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { Card } from './Card';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface StatCardProps {
|
||||
export interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon?: LucideIcon;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
delay?: number;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
export const StatCard = ({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
intent = 'primary',
|
||||
trend,
|
||||
variant = 'default',
|
||||
className = '',
|
||||
onClick,
|
||||
prefix,
|
||||
suffix,
|
||||
delay,
|
||||
}: StatCardProps) {
|
||||
const variantClasses = {
|
||||
default: 'bg-panel-gray border-border-gray',
|
||||
primary: 'bg-primary-accent/5 border-primary-accent/20',
|
||||
success: 'bg-success-green/5 border-success-green/20',
|
||||
warning: 'bg-warning-amber/5 border-warning-amber/20',
|
||||
danger: 'bg-critical-red/5 border-critical-red/20',
|
||||
info: 'bg-telemetry-aqua/5 border-telemetry-aqua/20',
|
||||
};
|
||||
|
||||
const iconBgClasses = {
|
||||
default: 'bg-white/5',
|
||||
primary: 'bg-primary-accent/10',
|
||||
success: 'bg-success-green/10',
|
||||
warning: 'bg-warning-amber/10',
|
||||
danger: 'bg-critical-red/10',
|
||||
info: 'bg-telemetry-aqua/10',
|
||||
};
|
||||
|
||||
const iconColorClasses = {
|
||||
default: 'text-gray-400',
|
||||
primary: 'text-primary-accent',
|
||||
success: 'text-success-green',
|
||||
warning: 'text-warning-amber',
|
||||
danger: 'text-critical-red',
|
||||
info: 'text-telemetry-aqua',
|
||||
};
|
||||
|
||||
const cardContent = (
|
||||
<Card variant="default" p={5} className={`${variantClasses[variant]} ${className} h-full`}>
|
||||
<Stack gap={3}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest">
|
||||
footer
|
||||
}: StatCardProps) => {
|
||||
return (
|
||||
<Card variant="default">
|
||||
<Box display="flex" alignItems="start" justifyContent="between" marginBottom={4}>
|
||||
<Box>
|
||||
<Text size="xs" weight="bold" variant="low" uppercase>
|
||||
{label}
|
||||
</Text>
|
||||
{icon && (
|
||||
<Box
|
||||
p={2}
|
||||
rounded="lg"
|
||||
bg={iconBgClasses[variant]}
|
||||
className={iconColorClasses[variant]}
|
||||
>
|
||||
<Icon icon={icon} size={5} />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack gap={1}>
|
||||
<Text size="3xl" weight="bold" color="text-white">
|
||||
{prefix}{value}{suffix}
|
||||
<Text size="2xl" weight="bold" variant="high" block marginTop={1}>
|
||||
{value}
|
||||
</Text>
|
||||
{trend && (
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Text
|
||||
size="xs"
|
||||
weight="bold"
|
||||
color={trend.isPositive ? 'text-success-green' : 'text-critical-red'}
|
||||
>
|
||||
{trend.isPositive ? '+' : ''}{trend.value}%
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500">
|
||||
vs last period
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
{icon && (
|
||||
<Box padding={2} rounded="lg" bg="var(--ui-color-bg-surface-muted)">
|
||||
<Icon icon={icon} size={5} intent={intent} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{trend && (
|
||||
<Box display="flex" alignItems="center" gap={1} marginBottom={footer ? 4 : 0}>
|
||||
<Text
|
||||
size="xs"
|
||||
weight="bold"
|
||||
variant={trend.isPositive ? 'success' : 'critical'}
|
||||
>
|
||||
{trend.isPositive ? '+' : '-'}{Math.abs(trend.value)}%
|
||||
</Text>
|
||||
<Text size="xs" variant="low">
|
||||
vs last period
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{footer && (
|
||||
<Box borderTop paddingTop={4}>
|
||||
{footer}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<Box as="button" onClick={onClick} w="full" textAlign="left" className="focus:outline-none">
|
||||
{cardContent}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return cardContent;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,58 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Grid } from './primitives/Grid';
|
||||
import { GridItem } from './primitives/GridItem';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { StatBox, StatBoxProps } from './StatBox';
|
||||
|
||||
type GridCols = 1 | 2 | 3 | 4 | 5 | 6 | 12;
|
||||
|
||||
interface StatItem {
|
||||
label: string;
|
||||
value: string | number;
|
||||
subValue?: string;
|
||||
color?: string;
|
||||
icon?: React.ElementType;
|
||||
export interface StatGridProps {
|
||||
stats: StatBoxProps[];
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
interface StatGridProps {
|
||||
stats: StatItem[];
|
||||
cols?: GridCols;
|
||||
mdCols?: GridCols;
|
||||
lgCols?: GridCols;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatGrid({ stats, cols = 2, mdCols = 3, lgCols = 4, className = '' }: StatGridProps) {
|
||||
export const StatGrid = ({
|
||||
stats,
|
||||
columns = 3
|
||||
}: StatGridProps) => {
|
||||
return (
|
||||
<Grid
|
||||
cols={cols}
|
||||
mdCols={mdCols}
|
||||
lgCols={lgCols}
|
||||
gap={4}
|
||||
className={className}
|
||||
>
|
||||
<Grid columns={columns} gap={4}>
|
||||
{stats.map((stat, index) => (
|
||||
<GridItem key={index}>
|
||||
<Surface variant="muted" padding={4} rounded="lg" border className="h-full">
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" color="text-gray-500" uppercase weight="semibold" letterSpacing="wider">
|
||||
{stat.label}
|
||||
</Text>
|
||||
<Stack direction="row" align="baseline" gap={2}>
|
||||
<Text size="2xl" weight="bold" font="mono" color={stat.color || 'text-white'}>
|
||||
{stat.value}
|
||||
</Text>
|
||||
{stat.subValue && (
|
||||
<Text size="xs" color="text-gray-500" font="mono">
|
||||
{stat.subValue}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
</GridItem>
|
||||
<StatBox key={index} {...stat} />
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Stack } from './primitives/Stack';
|
||||
|
||||
interface StatGridItemProps {
|
||||
export interface StatGridItemProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon?: LucideIcon;
|
||||
color?: string;
|
||||
icon?: React.ReactNode;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'high' | 'med' | 'low';
|
||||
color?: string; // Alias for intent
|
||||
}
|
||||
|
||||
/**
|
||||
* StatGridItem
|
||||
*
|
||||
* A simple stat display for use in a grid.
|
||||
*/
|
||||
export function StatGridItem({ label, value, icon, color = 'text-primary-blue' }: StatGridItemProps) {
|
||||
export const StatGridItem = ({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
intent = 'high',
|
||||
color
|
||||
}: StatGridItemProps) => {
|
||||
return (
|
||||
<Box p={4} textAlign="center">
|
||||
<Box padding={4} textAlign="center">
|
||||
{icon && (
|
||||
<Stack direction="row" align="center" justify="center" gap={2} mb={1} color={color}>
|
||||
<Icon icon={icon} size={4} />
|
||||
<Stack direction="row" align="center" justify="center" gap={2} marginBottom={1}>
|
||||
{icon}
|
||||
</Stack>
|
||||
)}
|
||||
<Text size="2xl" weight="bold" color="text-white" block>
|
||||
<Text size="2xl" weight="bold" variant={intent} color={color} block>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" weight="medium" color="text-gray-500" uppercase letterSpacing="wider">
|
||||
<Text size="xs" weight="medium" variant="low" uppercase letterSpacing="wider">
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,18 +2,23 @@ import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface StatItemProps {
|
||||
export interface StatItemProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
color?: string;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'high' | 'med' | 'low';
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
export function StatItem({ label, value, color = 'text-white', align = 'left' }: StatItemProps) {
|
||||
export const StatItem = ({
|
||||
label,
|
||||
value,
|
||||
intent = 'high',
|
||||
align = 'left'
|
||||
}: StatItemProps) => {
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" alignItems={align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start'}>
|
||||
<Text size="xs" color="text-gray-500" block mb={0.5}>{label}</Text>
|
||||
<Text size="sm" weight="semibold" color={color}>{value}</Text>
|
||||
<Box textAlign={align}>
|
||||
<Text size="xs" variant="low" block marginBottom={0.5}>{label}</Text>
|
||||
<Text size="sm" weight="semibold" variant={intent}>{value}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,36 +3,36 @@ import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Stack } from './primitives/Stack';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
export interface StatusBadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'pending';
|
||||
className?: string;
|
||||
icon?: LucideIcon;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({
|
||||
children,
|
||||
variant = 'success',
|
||||
className = '',
|
||||
icon,
|
||||
className
|
||||
}: StatusBadgeProps) {
|
||||
const variantClasses = {
|
||||
success: 'bg-performance-green/20 text-performance-green border-performance-green/30',
|
||||
warning: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30',
|
||||
error: 'bg-red-600/20 text-red-400 border-red-600/30',
|
||||
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
neutral: 'bg-iron-gray text-gray-400 border-charcoal-outline',
|
||||
pending: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30',
|
||||
success: 'bg-[var(--ui-color-intent-success)]/20 text-[var(--ui-color-intent-success)] border-[var(--ui-color-intent-success)]/30',
|
||||
warning: 'bg-[var(--ui-color-intent-warning)]/20 text-[var(--ui-color-intent-warning)] border-[var(--ui-color-intent-warning)]/30',
|
||||
error: 'bg-[var(--ui-color-intent-critical)]/20 text-[var(--ui-color-intent-critical)] border-[var(--ui-color-intent-critical)]/30',
|
||||
info: 'bg-[var(--ui-color-intent-primary)]/20 text-[var(--ui-color-intent-primary)] border-[var(--ui-color-intent-primary)]/30',
|
||||
neutral: 'bg-[var(--ui-color-bg-surface-muted)] text-[var(--ui-color-text-med)] border-[var(--ui-color-border-default)]',
|
||||
pending: 'bg-[var(--ui-color-intent-warning)]/20 text-[var(--ui-color-intent-warning)] border-[var(--ui-color-intent-warning)]/30',
|
||||
};
|
||||
|
||||
const classes = [
|
||||
'px-2 py-0.5 text-xs rounded-full border font-medium inline-flex items-center',
|
||||
'px-2 py-0.5 text-[10px] uppercase tracking-wider rounded-full border font-bold inline-flex items-center',
|
||||
variantClasses[variant],
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
].join(' ');
|
||||
|
||||
const content = icon ? (
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={icon} size={3} />
|
||||
{children}
|
||||
</Stack>
|
||||
@@ -43,4 +43,4 @@ export function StatusBadge({
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
interface StatusDotProps {
|
||||
color?: string;
|
||||
export interface StatusDotProps {
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
color?: string; // Alias for intent or custom color
|
||||
pulse?: boolean;
|
||||
size?: number;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg' | number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -14,28 +14,41 @@ interface StatusDotProps {
|
||||
* A simple status indicator dot with optional pulse effect.
|
||||
*/
|
||||
export function StatusDot({
|
||||
color = '#4ED4E0',
|
||||
intent = 'telemetry',
|
||||
color: colorProp,
|
||||
pulse = false,
|
||||
size = 2,
|
||||
className = ''
|
||||
size = 'md',
|
||||
}: StatusDotProps) {
|
||||
const sizeClass = `w-${size} h-${size}`;
|
||||
const intentColorMap = {
|
||||
primary: 'var(--ui-color-intent-primary)',
|
||||
success: 'var(--ui-color-intent-success)',
|
||||
warning: 'var(--ui-color-intent-warning)',
|
||||
critical: 'var(--ui-color-intent-critical)',
|
||||
telemetry: 'var(--ui-color-intent-telemetry)',
|
||||
};
|
||||
|
||||
const sizeMap = {
|
||||
sm: '0.375rem',
|
||||
md: '0.5rem',
|
||||
lg: '0.75rem',
|
||||
};
|
||||
|
||||
const color = colorProp || intentColorMap[intent];
|
||||
const dimension = typeof size === 'string' ? sizeMap[size] : `${size * 0.25}rem`;
|
||||
|
||||
return (
|
||||
<Box position="relative" className={`${sizeClass} ${className}`}>
|
||||
<Box position="relative" width={dimension} height={dimension}>
|
||||
<Box
|
||||
w="full"
|
||||
h="full"
|
||||
rounded="full"
|
||||
style={{ backgroundColor: color }}
|
||||
fullWidth
|
||||
fullHeight
|
||||
style={{ backgroundColor: color, borderRadius: '9999px' }}
|
||||
/>
|
||||
{pulse && (
|
||||
<Box
|
||||
position="absolute"
|
||||
inset={0}
|
||||
rounded="full"
|
||||
className="animate-ping"
|
||||
style={{ backgroundColor: color, opacity: 0.75 }}
|
||||
style={{ backgroundColor: color, opacity: 0.75, borderRadius: '9999px' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,110 +1,94 @@
|
||||
|
||||
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { StatusDot } from './StatusDot';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
interface StatusIndicatorProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
export { Badge };
|
||||
|
||||
export interface StatusIndicatorProps {
|
||||
status?: 'live' | 'upcoming' | 'completed' | 'cancelled' | 'pending';
|
||||
variant?: string; // Alias for status
|
||||
label?: string;
|
||||
subLabel?: string;
|
||||
variant: 'success' | 'warning' | 'danger' | 'info';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
icon?: any; // Alias for status dot
|
||||
}
|
||||
|
||||
export function StatusIndicator({ icon, label, subLabel, variant }: StatusIndicatorProps) {
|
||||
const colors = {
|
||||
success: {
|
||||
text: 'text-performance-green',
|
||||
bg: 'bg-green-500/10',
|
||||
border: 'border-green-500/30',
|
||||
icon: 'rgb(16, 185, 129)'
|
||||
export const StatusIndicator = ({
|
||||
status,
|
||||
variant,
|
||||
label,
|
||||
subLabel,
|
||||
size = 'md',
|
||||
icon
|
||||
}: StatusIndicatorProps) => {
|
||||
const activeStatus = (status || variant || 'pending') as any;
|
||||
|
||||
const configMap: any = {
|
||||
live: {
|
||||
intent: 'success' as const,
|
||||
pulse: true,
|
||||
text: 'Live',
|
||||
},
|
||||
warning: {
|
||||
text: 'text-warning-amber',
|
||||
bg: 'bg-yellow-500/10',
|
||||
border: 'border-yellow-500/30',
|
||||
icon: 'rgb(245, 158, 11)'
|
||||
upcoming: {
|
||||
intent: 'primary' as const,
|
||||
pulse: false,
|
||||
text: 'Upcoming',
|
||||
},
|
||||
completed: {
|
||||
intent: 'telemetry' as const,
|
||||
pulse: false,
|
||||
text: 'Completed',
|
||||
},
|
||||
cancelled: {
|
||||
intent: 'critical' as const,
|
||||
pulse: false,
|
||||
text: 'Cancelled',
|
||||
},
|
||||
pending: {
|
||||
intent: 'warning' as const,
|
||||
pulse: false,
|
||||
text: 'Pending',
|
||||
},
|
||||
success: {
|
||||
intent: 'success' as const,
|
||||
pulse: false,
|
||||
text: 'Success',
|
||||
},
|
||||
danger: {
|
||||
text: 'text-red-400',
|
||||
bg: 'bg-red-500/10',
|
||||
border: 'border-red-500/30',
|
||||
icon: 'rgb(239, 68, 68)'
|
||||
intent: 'critical' as const,
|
||||
pulse: false,
|
||||
text: 'Danger',
|
||||
},
|
||||
warning: {
|
||||
intent: 'warning' as const,
|
||||
pulse: false,
|
||||
text: 'Warning',
|
||||
},
|
||||
info: {
|
||||
text: 'text-primary-blue',
|
||||
bg: 'bg-blue-500/10',
|
||||
border: 'border-blue-500/30',
|
||||
icon: 'rgb(59, 130, 246)'
|
||||
}
|
||||
};
|
||||
|
||||
const config = colors[variant];
|
||||
const config = configMap[activeStatus] || configMap.pending;
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
p={2}
|
||||
rounded="lg"
|
||||
bg={config.bg}
|
||||
border
|
||||
borderColor={config.border}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Icon icon={icon} size={4} color={config.icon} />
|
||||
<Text size="sm" weight="semibold" color="text-white">{label}</Text>
|
||||
</Box>
|
||||
{subLabel && (
|
||||
<Text size="xs" color="text-gray-400">
|
||||
{subLabel}
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<StatusDot intent={config.intent} pulse={config.pulse} size={size === 'lg' ? 'lg' : size === 'sm' ? 'sm' : 'md'} />
|
||||
<Box>
|
||||
<Text size={size === 'lg' ? 'md' : 'sm'} weight="bold" variant="high" uppercase>
|
||||
{label || config.text}
|
||||
</Text>
|
||||
)}
|
||||
{subLabel && <Text size="xs" variant="low">{subLabel}</Text>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface StatRowProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
valueColor?: string;
|
||||
valueFont?: 'sans' | 'mono';
|
||||
}
|
||||
|
||||
export function StatRow({ label, value, valueColor = 'text-white', valueFont = 'sans' }: StatRowProps) {
|
||||
return (
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Text size="xs" color="text-gray-500">{label}</Text>
|
||||
<Text size="xs" weight="bold" color={valueColor} font={valueFont}>
|
||||
{value}
|
||||
</Text>
|
||||
export const StatRow = ({ label, value, subLabel, variant, valueColor, valueFont }: { label: string, value: string, subLabel?: string, variant?: string, valueColor?: string, valueFont?: string }) => (
|
||||
<Box display="flex" alignItems="center" justifyContent="between" paddingY={2} borderBottom>
|
||||
<Box>
|
||||
<Text size="sm" variant="high">{label}</Text>
|
||||
{subLabel && <Text size="xs" variant="low">{subLabel}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant: 'success' | 'warning' | 'danger' | 'info' | 'gray';
|
||||
size?: 'xs' | 'sm';
|
||||
}
|
||||
|
||||
export function Badge({ children, variant, size = 'xs' }: BadgeProps) {
|
||||
const variants = {
|
||||
success: 'bg-green-500/20 text-performance-green',
|
||||
warning: 'bg-yellow-500/20 text-warning-amber',
|
||||
danger: 'bg-red-500/20 text-red-400',
|
||||
info: 'bg-blue-500/20 text-primary-blue',
|
||||
gray: 'bg-gray-500/20 text-gray-400'
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={1} rounded="sm" bg={variants[variant]}>
|
||||
<Text size={size} color="inherit">
|
||||
{children}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
<Text size="sm" weight="bold" variant={variant as any || 'high'} color={valueColor} font={valueFont as any}>{value}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,65 +1,63 @@
|
||||
import { User, Camera, Check } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Step {
|
||||
id: number;
|
||||
export interface Step {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number;
|
||||
steps?: Step[];
|
||||
export interface StepIndicatorProps {
|
||||
steps: Step[];
|
||||
currentStepId: string;
|
||||
completedStepIds: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_STEPS: Step[] = [
|
||||
{ id: 1, label: 'Personal', icon: User },
|
||||
{ id: 2, label: 'Avatar', icon: Camera },
|
||||
];
|
||||
|
||||
export function StepIndicator({ currentStep, steps = DEFAULT_STEPS }: StepIndicatorProps) {
|
||||
export const StepIndicator = ({
|
||||
steps,
|
||||
currentStepId,
|
||||
completedStepIds
|
||||
}: StepIndicatorProps) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
{steps.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
const isCompleted = step.id < currentStep;
|
||||
const isCurrent = step.id === currentStep;
|
||||
const isCurrent = step.id === currentStepId;
|
||||
const isCompleted = completedStepIds.includes(step.id);
|
||||
const isLast = index === steps.length - 1;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full transition-all duration-300 ${
|
||||
isCurrent
|
||||
? 'bg-primary-blue text-white shadow-lg shadow-primary-blue/30'
|
||||
: isCompleted
|
||||
? 'bg-performance-green text-white'
|
||||
: 'bg-iron-gray border border-charcoal-outline text-gray-500'
|
||||
}`}
|
||||
<React.Fragment key={step.id}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Box
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
bg={isCompleted ? 'var(--ui-color-intent-success)' : isCurrent ? 'var(--ui-color-intent-primary)' : 'var(--ui-color-bg-surface-muted)'}
|
||||
style={{ border: isCurrent ? '2px solid var(--ui-color-intent-primary)' : 'none' }}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="w-5 h-5" />
|
||||
<Icon icon={Check} size={4} intent="high" />
|
||||
) : (
|
||||
<Icon className="w-5 h-5" />
|
||||
<Text size="xs" weight="bold" variant={isCurrent ? 'high' : 'low'}>
|
||||
{index + 1}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`mt-2 text-xs font-medium ${
|
||||
isCurrent ? 'text-white' : isCompleted ? 'text-performance-green' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
</Box>
|
||||
<Text size="sm" weight={isCurrent ? 'bold' : 'medium'} variant={isCurrent ? 'high' : 'low'}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`w-16 h-0.5 mx-4 mt-[-20px] ${
|
||||
isCompleted ? 'bg-performance-green' : 'bg-charcoal-outline'
|
||||
}`}
|
||||
/>
|
||||
</Text>
|
||||
</Box>
|
||||
{!isLast && (
|
||||
<Box flex={1} height="2px" bg="var(--ui-color-border-muted)" minWidth="2rem" />
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,56 +1,46 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface SummaryItemProps {
|
||||
label?: string;
|
||||
value?: string | number;
|
||||
icon?: LucideIcon;
|
||||
export interface SummaryItemProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
rightContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SummaryItem({ label, value, icon, onClick, title, subtitle, rightContent }: SummaryItemProps) {
|
||||
export const SummaryItem = ({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
intent = 'primary',
|
||||
onClick
|
||||
}: SummaryItemProps) => {
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
p={4}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={4}
|
||||
cursor={onClick ? 'pointer' : 'default'}
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
padding={4}
|
||||
onClick={onClick}
|
||||
hoverBg={onClick ? 'bg-white/5' : undefined}
|
||||
transition={!!onClick}
|
||||
style={{ cursor: onClick ? 'pointer' : 'default' }}
|
||||
>
|
||||
{icon && (
|
||||
<Box p={2} rounded="lg" bg="bg-white/5">
|
||||
<Icon icon={icon} size={5} color="text-gray-400" />
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box padding={2} rounded="lg" bg="var(--ui-color-bg-surface-muted)">
|
||||
<Icon icon={icon} size={5} intent={intent} />
|
||||
</Box>
|
||||
)}
|
||||
<Box flex={1}>
|
||||
{(label || title) && (
|
||||
<Text size="xs" color="text-gray-500" uppercase letterSpacing="wider" block>
|
||||
{label || title}
|
||||
</Text>
|
||||
)}
|
||||
{(value || subtitle) && (
|
||||
<Text size="lg" weight="bold" color="text-white" block>
|
||||
{value || subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{rightContent && (
|
||||
<Box>
|
||||
{rightContent}
|
||||
<Text size="xs" weight="bold" variant="low" uppercase>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="lg" weight="bold" variant="high" block marginTop={0.5}>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,68 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface Tab {
|
||||
export interface TabNavigationOption {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface TabNavigationProps {
|
||||
tabs: Tab[];
|
||||
activeTab: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
className?: string;
|
||||
export interface TabNavigationProps {
|
||||
options: TabNavigationOption[];
|
||||
activeId: string;
|
||||
onChange: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TabNavigation({ tabs, activeTab, onTabChange, className = '' }: TabNavigationProps) {
|
||||
export const TabNavigation = ({
|
||||
options,
|
||||
activeId,
|
||||
onChange
|
||||
}: TabNavigationProps) => {
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
p={1}
|
||||
display="inline-flex"
|
||||
zIndex={10}
|
||||
className={className}
|
||||
>
|
||||
<Stack direction="row" gap={1}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<Surface
|
||||
key={tab.id}
|
||||
as="button"
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
variant={isActive ? 'default' : 'ghost'}
|
||||
bg={isActive ? 'bg-primary-blue' : ''}
|
||||
rounded="lg"
|
||||
px={4}
|
||||
py={2}
|
||||
transition="all 0.2s"
|
||||
group
|
||||
className={`select-none ${isActive ? 'shadow-lg shadow-primary-blue/25' : 'hover:bg-iron-gray/80'}`}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{tab.icon && (
|
||||
<Box color={isActive ? 'text-white' : 'text-gray-400'} groupHoverTextColor={!isActive ? 'white' : undefined}>
|
||||
{tab.icon}
|
||||
</Box>
|
||||
)}
|
||||
<Text
|
||||
size="sm"
|
||||
weight="medium"
|
||||
color={isActive ? 'text-white' : 'text-gray-400'}
|
||||
groupHoverTextColor={!isActive ? 'white' : undefined}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
<Surface variant="muted" rounded="xl" padding={1} display="inline-flex">
|
||||
{options.map((option) => {
|
||||
const isActive = option.id === activeId;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => onChange(option.id)}
|
||||
className={`px-4 py-2 text-xs font-bold uppercase tracking-widest transition-all rounded-lg ${
|
||||
isActive
|
||||
? 'bg-[var(--ui-color-bg-surface)] text-[var(--ui-color-intent-primary)] shadow-sm'
|
||||
: 'text-[var(--ui-color-text-low)] hover:text-[var(--ui-color-text-high)]'
|
||||
}`}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</Box>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,95 +1,87 @@
|
||||
import React, { ReactNode, ElementType } from 'react';
|
||||
import { Box, BoxProps } from './primitives/Box';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
interface TableProps extends BoxProps<'table'> {
|
||||
export interface TableProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Table({ children, className = '', ...props }: TableProps) {
|
||||
const { border, translate, ...rest } = props;
|
||||
export const Table = ({ children, className }: TableProps) => {
|
||||
return (
|
||||
<Box overflow="auto" border borderColor="border-border-gray" rounded="sm">
|
||||
<table className={`w-full border-collapse text-left ${className}`} {...(rest as any)}>
|
||||
<Surface rounded="lg" shadow="sm" style={{ overflow: 'auto', border: '1px solid var(--ui-color-border-default)' }} className={className}>
|
||||
<table className="w-full border-collapse text-left">
|
||||
{children}
|
||||
</table>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface TableHeaderProps extends BoxProps<'thead'> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function TableHeader({ children, className = '', ...props }: TableHeaderProps) {
|
||||
export const TableHeader = ({ children, className, textAlign, w }: { children: ReactNode, className?: string, textAlign?: 'left' | 'center' | 'right', w?: string }) => {
|
||||
return (
|
||||
<Box as="thead" className={`bg-graphite-black border-b border-border-gray ${className}`} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
<thead className={`bg-[var(--ui-color-bg-base)] border-b border-[var(--ui-color-border-default)] ${className || ''}`}>
|
||||
<tr>
|
||||
{React.Children.map(children, child => {
|
||||
if (React.isValidElement(child)) {
|
||||
return React.cloneElement(child as any, { textAlign: textAlign || (child.props as any).textAlign, w: w || (child.props as any).w });
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const TableHead = TableHeader;
|
||||
|
||||
interface TableBodyProps extends BoxProps<'tbody'> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function TableBody({ children, className = '', ...props }: TableBodyProps) {
|
||||
export const TableBody = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<Box as="tbody" className={`divide-y divide-border-gray/50 ${className}`} {...props}>
|
||||
<tbody className="divide-y divide-[var(--ui-color-border-muted)]">
|
||||
{children}
|
||||
</Box>
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableRowProps extends BoxProps<'tr'> {
|
||||
children: ReactNode;
|
||||
hoverBg?: string;
|
||||
clickable?: boolean;
|
||||
variant?: string;
|
||||
}
|
||||
|
||||
export function TableRow({ children, className = '', hoverBg, clickable, variant, ...props }: TableRowProps) {
|
||||
const classes = [
|
||||
'transition-colors',
|
||||
clickable || props.onClick ? 'cursor-pointer' : '',
|
||||
hoverBg ? `hover:${hoverBg}` : (clickable || props.onClick ? 'hover:bg-white/5' : ''),
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
};
|
||||
|
||||
export const TableRow = ({ children, onClick, className, variant, clickable, bg, ...props }: { children: ReactNode, onClick?: () => void, className?: string, variant?: string, clickable?: boolean, bg?: string, [key: string]: any }) => {
|
||||
const isClickable = clickable || !!onClick;
|
||||
return (
|
||||
<Box as="tr" className={classes} {...props}>
|
||||
<tr
|
||||
className={`${isClickable ? 'cursor-pointer hover:bg-white/5 transition-colors' : ''} ${variant === 'highlight' ? 'bg-white/5' : ''} ${className || ''}`}
|
||||
onClick={onClick}
|
||||
style={bg ? { backgroundColor: bg.startsWith('bg-') ? undefined : bg } : undefined}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableCellProps extends BoxProps<'td'> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function TableHeaderCell({ children, className = '', ...props }: TableCellProps) {
|
||||
const classes = [
|
||||
'px-4 py-3 text-xs font-bold text-gray-400 uppercase tracking-wider',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
};
|
||||
|
||||
export const TableHeaderCell = ({ children, textAlign, w, className }: { children: ReactNode, textAlign?: 'left' | 'center' | 'right', w?: string, className?: string }) => {
|
||||
const alignClass = textAlign === 'center' ? 'text-center' : (textAlign === 'right' ? 'text-right' : 'text-left');
|
||||
return (
|
||||
<Box as="th" className={classes} {...props}>
|
||||
<th
|
||||
className={`px-4 py-3 text-xs font-bold uppercase tracking-wider text-[var(--ui-color-text-low)] ${alignClass} ${className || ''}`}
|
||||
style={w ? { width: w } : undefined}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCell({ children, className = '', ...props }: TableCellProps) {
|
||||
const classes = [
|
||||
'px-4 py-4 text-sm text-gray-300',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
};
|
||||
|
||||
export const TableCell = ({ children, textAlign, className, py, colSpan, w, position, ...props }: { children: ReactNode, textAlign?: 'left' | 'center' | 'right', className?: string, py?: number, colSpan?: number, w?: string, position?: string, [key: string]: any }) => {
|
||||
const alignClass = textAlign === 'center' ? 'text-center' : (textAlign === 'right' ? 'text-right' : 'text-left');
|
||||
return (
|
||||
<Box as="td" className={classes} {...props}>
|
||||
<td
|
||||
className={`px-4 py-3 text-sm text-[var(--ui-color-text-high)] ${alignClass} ${className || ''}`}
|
||||
colSpan={colSpan}
|
||||
style={{
|
||||
...(py !== undefined ? { paddingTop: `${py * 0.25}rem`, paddingBottom: `${py * 0.25}rem` } : {}),
|
||||
...(w ? { width: w } : {}),
|
||||
...(position ? { position: position as any } : {}),
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,206 +1,121 @@
|
||||
import React, { ReactNode, ElementType, ComponentPropsWithoutRef } from 'react';
|
||||
import { Box, BoxProps } from './primitives/Box';
|
||||
import React, { ReactNode, forwardRef, ElementType } from 'react';
|
||||
import { Box, BoxProps, ResponsiveValue } from './primitives/Box';
|
||||
|
||||
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
|
||||
export type TextSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | 'base';
|
||||
|
||||
type TextSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
|
||||
|
||||
interface ResponsiveTextSize {
|
||||
base?: TextSize;
|
||||
sm?: TextSize;
|
||||
md?: TextSize;
|
||||
lg?: TextSize;
|
||||
xl?: TextSize;
|
||||
'2xl'?: TextSize;
|
||||
}
|
||||
|
||||
type TextAlign = 'left' | 'center' | 'right';
|
||||
|
||||
interface ResponsiveTextAlign {
|
||||
base?: TextAlign;
|
||||
sm?: TextAlign;
|
||||
md?: TextAlign;
|
||||
lg?: TextAlign;
|
||||
xl?: TextAlign;
|
||||
'2xl'?: TextAlign;
|
||||
}
|
||||
|
||||
interface TextProps<T extends ElementType = 'span'> extends Omit<BoxProps<T>, 'children' | 'className' | 'size'> {
|
||||
as?: T;
|
||||
export interface TextProps extends BoxProps<any> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
size?: TextSize | ResponsiveTextSize;
|
||||
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold' | string;
|
||||
color?: string;
|
||||
font?: 'mono' | 'sans' | string;
|
||||
align?: TextAlign | ResponsiveTextAlign;
|
||||
truncate?: boolean;
|
||||
uppercase?: boolean;
|
||||
capitalize?: boolean;
|
||||
letterSpacing?: 'tighter' | 'tight' | 'normal' | 'wide' | 'wider' | 'widest' | '0.05em' | string;
|
||||
leading?: 'none' | 'tight' | 'snug' | 'normal' | 'relaxed' | 'loose';
|
||||
fontSize?: string;
|
||||
style?: React.CSSProperties;
|
||||
block?: boolean;
|
||||
variant?: 'high' | 'med' | 'low' | 'primary' | 'success' | 'warning' | 'critical' | 'inherit';
|
||||
size?: TextSize | ResponsiveValue<TextSize>;
|
||||
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
|
||||
as?: ElementType;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
italic?: boolean;
|
||||
lineClamp?: number;
|
||||
ml?: Spacing | ResponsiveSpacing;
|
||||
mr?: Spacing | ResponsiveSpacing;
|
||||
mt?: Spacing | ResponsiveSpacing;
|
||||
mb?: Spacing | ResponsiveSpacing;
|
||||
mono?: boolean;
|
||||
block?: boolean;
|
||||
uppercase?: boolean;
|
||||
letterSpacing?: string;
|
||||
leading?: 'none' | 'tight' | 'snug' | 'normal' | 'relaxed' | 'loose';
|
||||
truncate?: boolean;
|
||||
lineHeight?: string | number;
|
||||
font?: 'sans' | 'mono';
|
||||
hoverTextColor?: string;
|
||||
}
|
||||
|
||||
interface ResponsiveSpacing {
|
||||
base?: Spacing;
|
||||
sm?: Spacing;
|
||||
md?: Spacing;
|
||||
lg?: Spacing;
|
||||
xl?: Spacing;
|
||||
'2xl'?: Spacing;
|
||||
}
|
||||
|
||||
export function Text<T extends ElementType = 'span'>({
|
||||
as,
|
||||
export const Text = forwardRef<HTMLElement, TextProps>(({
|
||||
children,
|
||||
className = '',
|
||||
size = 'base',
|
||||
variant = 'med',
|
||||
size = 'md',
|
||||
weight = 'normal',
|
||||
color = '',
|
||||
font = 'sans',
|
||||
as = 'p',
|
||||
align = 'left',
|
||||
truncate = false,
|
||||
italic = false,
|
||||
mono = false,
|
||||
block = false,
|
||||
uppercase = false,
|
||||
capitalize = false,
|
||||
letterSpacing,
|
||||
leading,
|
||||
fontSize,
|
||||
style,
|
||||
block = false,
|
||||
italic = false,
|
||||
lineClamp,
|
||||
ml, mr, mt, mb,
|
||||
truncate = false,
|
||||
lineHeight,
|
||||
font,
|
||||
hoverTextColor,
|
||||
...props
|
||||
}: TextProps<T> & ComponentPropsWithoutRef<T>) {
|
||||
const Tag = (as as ElementType) || 'span';
|
||||
|
||||
const sizeClasses: Record<string, string> = {
|
||||
}, ref) => {
|
||||
const variantClasses = {
|
||||
high: 'text-[var(--ui-color-text-high)]',
|
||||
med: 'text-[var(--ui-color-text-med)]',
|
||||
low: 'text-[var(--ui-color-text-low)]',
|
||||
primary: 'text-[var(--ui-color-intent-primary)]',
|
||||
success: 'text-[var(--ui-color-intent-success)]',
|
||||
warning: 'text-[var(--ui-color-intent-warning)]',
|
||||
critical: 'text-[var(--ui-color-intent-critical)]',
|
||||
inherit: 'text-inherit',
|
||||
};
|
||||
|
||||
const sizeMap: Record<TextSize, string> = {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
base: 'text-base',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg',
|
||||
xl: 'text-xl',
|
||||
'2xl': 'text-2xl',
|
||||
'3xl': 'text-3xl',
|
||||
'4xl': 'text-4xl'
|
||||
'4xl': 'text-4xl',
|
||||
};
|
||||
|
||||
const getSizeClasses = (value: TextSize | ResponsiveTextSize | undefined) => {
|
||||
if (value === undefined) return '';
|
||||
if (typeof value === 'object') {
|
||||
const classes = [];
|
||||
if (value.base) classes.push(sizeClasses[value.base]);
|
||||
if (value.sm) classes.push(`sm:${sizeClasses[value.sm]}`);
|
||||
if (value.md) classes.push(`md:${sizeClasses[value.md]}`);
|
||||
if (value.lg) classes.push(`lg:${sizeClasses[value.lg]}`);
|
||||
if (value.xl) classes.push(`xl:${sizeClasses[value.xl]}`);
|
||||
if (value['2xl']) classes.push(`2xl:${sizeClasses[value['2xl']]}`);
|
||||
return classes.join(' ');
|
||||
}
|
||||
return sizeClasses[value];
|
||||
const getResponsiveSizeClasses = (value: TextSize | ResponsiveValue<TextSize>) => {
|
||||
if (typeof value === 'string') return sizeMap[value];
|
||||
const classes = [];
|
||||
if (value.base) classes.push(sizeMap[value.base]);
|
||||
if (value.sm) classes.push(`sm:${sizeMap[value.sm]}`);
|
||||
if (value.md) classes.push(`md:${sizeMap[value.md]}`);
|
||||
if (value.lg) classes.push(`lg:${sizeMap[value.lg]}`);
|
||||
if (value.xl) classes.push(`xl:${sizeMap[value.xl]}`);
|
||||
return classes.join(' ');
|
||||
};
|
||||
|
||||
const weightClasses: Record<string, string> = {
|
||||
const weightClasses = {
|
||||
light: 'font-light',
|
||||
normal: 'font-normal',
|
||||
medium: 'font-medium',
|
||||
semibold: 'font-semibold',
|
||||
bold: 'font-bold'
|
||||
};
|
||||
|
||||
const fontClasses: Record<string, string> = {
|
||||
mono: 'font-mono',
|
||||
sans: 'font-sans'
|
||||
};
|
||||
|
||||
const alignClasses: Record<string, string> = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right'
|
||||
bold: 'font-bold',
|
||||
};
|
||||
|
||||
const getAlignClasses = (value: TextAlign | ResponsiveTextAlign | undefined) => {
|
||||
if (value === undefined) return '';
|
||||
if (typeof value === 'object') {
|
||||
const classes = [];
|
||||
if (value.base) classes.push(alignClasses[value.base]);
|
||||
if (value.sm) classes.push(`sm:${alignClasses[value.sm]}`);
|
||||
if (value.md) classes.push(`md:${alignClasses[value.md]}`);
|
||||
if (value.lg) classes.push(`lg:${alignClasses[value.lg]}`);
|
||||
if (value.xl) classes.push(`xl:${alignClasses[value.xl]}`);
|
||||
if (value['2xl']) classes.push(`2xl:${alignClasses[value['2xl']]}`);
|
||||
return classes.join(' ');
|
||||
}
|
||||
return alignClasses[value];
|
||||
};
|
||||
|
||||
const spacingMap: Record<number, string> = {
|
||||
0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4',
|
||||
5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14',
|
||||
16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44',
|
||||
48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96'
|
||||
};
|
||||
|
||||
const leadingClasses: Record<string, string> = {
|
||||
const leadingClasses = {
|
||||
none: 'leading-none',
|
||||
tight: 'leading-tight',
|
||||
snug: 'leading-snug',
|
||||
normal: 'leading-normal',
|
||||
relaxed: 'leading-relaxed',
|
||||
loose: 'leading-loose'
|
||||
loose: 'leading-loose',
|
||||
};
|
||||
|
||||
const getSpacingClass = (prefix: string, value: Spacing | ResponsiveSpacing | undefined) => {
|
||||
if (value === undefined) return '';
|
||||
if (typeof value === 'object') {
|
||||
const classes = [];
|
||||
if (value.base !== undefined) classes.push(`${prefix}-${spacingMap[value.base as number]}`);
|
||||
if (value.sm !== undefined) classes.push(`sm:${prefix}-${spacingMap[value.sm as number]}`);
|
||||
if (value.md !== undefined) classes.push(`md:${prefix}-${spacingMap[value.md as number]}`);
|
||||
if (value.lg !== undefined) classes.push(`lg:${prefix}-${spacingMap[value.lg as number]}`);
|
||||
if (value.xl !== undefined) classes.push(`xl:${prefix}-${spacingMap[value.xl as number]}`);
|
||||
if (value['2xl'] !== undefined) classes.push(`2xl:${prefix}-${spacingMap[value['2xl'] as number]}`);
|
||||
return classes.join(' ');
|
||||
}
|
||||
return `${prefix}-${spacingMap[value as number]}`;
|
||||
};
|
||||
|
||||
|
||||
const classes = [
|
||||
block ? 'block' : 'inline',
|
||||
getSizeClasses(size),
|
||||
weightClasses[weight] || '',
|
||||
fontClasses[font] || '',
|
||||
getAlignClasses(align),
|
||||
leading ? leadingClasses[leading] : '',
|
||||
color,
|
||||
truncate ? 'truncate' : '',
|
||||
uppercase ? 'uppercase' : '',
|
||||
capitalize ? 'capitalize' : '',
|
||||
variantClasses[variant],
|
||||
getResponsiveSizeClasses(size),
|
||||
weightClasses[weight],
|
||||
align === 'center' ? 'text-center' : (align === 'right' ? 'text-right' : 'text-left'),
|
||||
italic ? 'italic' : '',
|
||||
lineClamp ? `line-clamp-${lineClamp}` : '',
|
||||
letterSpacing === '0.05em' ? 'tracking-wider' : letterSpacing ? `tracking-${letterSpacing}` : '',
|
||||
getSpacingClass('ml', ml),
|
||||
getSpacingClass('mr', mr),
|
||||
getSpacingClass('mt', mt),
|
||||
getSpacingClass('mb', mb),
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const combinedStyle = {
|
||||
...(fontSize ? { fontSize } : {}),
|
||||
...(weight && !weightClasses[weight] ? { fontWeight: weight } : {}),
|
||||
...(font && !fontClasses[font] ? { fontFamily: font } : {}),
|
||||
...style
|
||||
(mono || font === 'mono') ? 'font-mono' : 'font-sans',
|
||||
block ? 'block' : 'inline',
|
||||
uppercase ? 'uppercase tracking-wider' : '',
|
||||
leading ? leadingClasses[leading] : '',
|
||||
truncate ? 'truncate' : '',
|
||||
hoverTextColor ? `hover:text-${hoverTextColor}` : '',
|
||||
].join(' ');
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...(letterSpacing ? { letterSpacing } : {}),
|
||||
...(lineHeight ? { lineHeight } : {}),
|
||||
};
|
||||
|
||||
return <Box as={Tag} className={classes} style={combinedStyle} {...props}>{children}</Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box as={as} ref={ref} className={classes} style={style} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
Text.displayName = 'Text';
|
||||
|
||||
@@ -1,49 +1,61 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import React, { forwardRef, TextareaHTMLAttributes } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
export interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
errorMessage?: string;
|
||||
variant?: 'default' | 'error';
|
||||
error?: string;
|
||||
hint?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
({ label, errorMessage, variant = 'default', fullWidth = true, className = '', ...props }, ref) => {
|
||||
const isError = variant === 'error' || !!errorMessage;
|
||||
|
||||
return (
|
||||
<Stack gap={1.5} fullWidth={fullWidth}>
|
||||
{label && (
|
||||
<Text as="label" size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
|
||||
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
fullWidth = false,
|
||||
...props
|
||||
}, ref) => {
|
||||
const baseClasses = 'bg-[var(--ui-color-bg-surface)] border border-[var(--ui-color-border-default)] text-[var(--ui-color-text-high)] placeholder-[var(--ui-color-text-low)] focus:outline-none focus:border-[var(--ui-color-intent-primary)] transition-colors p-3 text-sm min-h-[100px]';
|
||||
const errorClasses = error ? 'border-[var(--ui-color-intent-critical)]' : '';
|
||||
const widthClasses = fullWidth ? 'w-full' : '';
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
errorClasses,
|
||||
widthClasses,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Box width={fullWidth ? '100%' : undefined}>
|
||||
{label && (
|
||||
<Box marginBottom={1.5}>
|
||||
<Text as="label" size="xs" weight="bold" variant="low">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<Box position="relative" fullWidth={fullWidth}>
|
||||
<Box
|
||||
as="textarea"
|
||||
ref={ref}
|
||||
fullWidth={fullWidth}
|
||||
p={3}
|
||||
bg="bg-deep-graphite"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
border
|
||||
borderColor={isError ? 'var(--warning-amber)' : 'rgba(38, 38, 38, 0.8)'}
|
||||
className={`placeholder:text-gray-500 focus:ring-2 focus:ring-primary-blue transition-all duration-150 sm:text-sm ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<Text size="xs" color="text-warning-amber" mt={1}>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
);
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={classes}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text size="xs" variant="critical">
|
||||
{error}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{hint && !error && (
|
||||
<Box marginTop={1}>
|
||||
<Text size="xs" variant="low">
|
||||
{hint}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
TextArea.displayName = 'TextArea';
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface ToggleProps {
|
||||
export interface ToggleProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
@@ -11,64 +10,49 @@ interface ToggleProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Toggle({ label, description, checked, onChange, disabled }: ToggleProps) {
|
||||
export const Toggle = ({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false
|
||||
}: ToggleProps) => {
|
||||
return (
|
||||
<Box
|
||||
as="label"
|
||||
display="flex"
|
||||
alignItems="start"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
cursor={disabled ? 'not-allowed' : 'pointer'}
|
||||
py={3}
|
||||
paddingY={3}
|
||||
borderBottom
|
||||
borderColor="border-charcoal-outline/50"
|
||||
className="last:border-b-0"
|
||||
opacity={disabled ? 0.5 : 1}
|
||||
style={{ cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.5 : 1 }}
|
||||
>
|
||||
<Box flex={1} pr={4}>
|
||||
<Text weight="medium" color="text-gray-200" block>{label}</Text>
|
||||
<Box flex={1} paddingRight={4}>
|
||||
<Text weight="medium" variant="high" block>{label}</Text>
|
||||
{description && (
|
||||
<Text size="xs" color="text-gray-500" block mt={1}>
|
||||
<Text size="xs" variant="low" block marginTop={1}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box position="relative">
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
disabled={disabled}
|
||||
w="12"
|
||||
h="6"
|
||||
rounded="full"
|
||||
transition="all 0.2s"
|
||||
flexShrink={0}
|
||||
ring="primary-blue/50"
|
||||
bg={checked ? 'bg-primary-blue/20' : 'bg-charcoal-outline'}
|
||||
className="focus:outline-none focus:ring-2"
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-primary-blue"
|
||||
initial={{ boxShadow: '0 0 0px rgba(25, 140, 255, 0)' }}
|
||||
animate={{
|
||||
opacity: checked ? 1 : 0,
|
||||
boxShadow: checked ? '0 0 10px rgba(25, 140, 255, 0.4)' : '0 0 0px rgba(25, 140, 255, 0)'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<motion.span
|
||||
className="absolute top-0.5 w-5 h-5 bg-white rounded-full shadow-md"
|
||||
initial={false}
|
||||
animate={{
|
||||
left: checked ? '26px' : '2px',
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
disabled={disabled}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
checked ? 'bg-[var(--ui-color-intent-primary)]' : 'bg-[var(--ui-color-bg-surface-muted)]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
checked ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</Box>
|
||||
</button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { forwardRef, ForwardedRef, ElementType, ComponentPropsWithoutRef } from 'react';
|
||||
import React, { forwardRef, ForwardedRef, ElementType } from 'react';
|
||||
|
||||
/**
|
||||
* WARNING: DO NOT VIOLATE THE PURPOSE OF THIS PRIMITIVE.
|
||||
*
|
||||
* Box is a basic container primitive for spacing, sizing and basic styling.
|
||||
* Box is a basic container primitive for spacing, sizing and basic layout.
|
||||
*
|
||||
* - DO NOT add layout props (flex, grid, gap) - use Stack or Grid instead.
|
||||
* - DO NOT add decoration props (bg, border, shadow) - use Surface instead.
|
||||
* - DO NOT add positioning props (absolute, top, zIndex) - create a specific component.
|
||||
* - DO NOT add animation props - create a specific component.
|
||||
*
|
||||
@@ -34,8 +34,23 @@ export type ResponsiveValue<T> = {
|
||||
export interface BoxProps<T extends ElementType> {
|
||||
as?: T;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
// Spacing
|
||||
margin?: Spacing | ResponsiveSpacing;
|
||||
marginTop?: Spacing | ResponsiveSpacing;
|
||||
marginBottom?: Spacing | ResponsiveSpacing;
|
||||
marginLeft?: Spacing | 'auto' | ResponsiveSpacing;
|
||||
marginRight?: Spacing | 'auto' | ResponsiveSpacing;
|
||||
marginX?: Spacing | 'auto' | ResponsiveSpacing;
|
||||
marginY?: Spacing | ResponsiveSpacing;
|
||||
padding?: Spacing | ResponsiveSpacing;
|
||||
paddingTop?: Spacing | ResponsiveSpacing;
|
||||
paddingBottom?: Spacing | ResponsiveSpacing;
|
||||
paddingLeft?: Spacing | ResponsiveSpacing;
|
||||
paddingRight?: Spacing | ResponsiveSpacing;
|
||||
paddingX?: Spacing | ResponsiveSpacing;
|
||||
paddingY?: Spacing | ResponsiveSpacing;
|
||||
|
||||
// Aliases (Deprecated - use full names)
|
||||
m?: Spacing | ResponsiveSpacing;
|
||||
mt?: Spacing | ResponsiveSpacing;
|
||||
mb?: Spacing | ResponsiveSpacing;
|
||||
@@ -50,11 +65,10 @@ export interface BoxProps<T extends ElementType> {
|
||||
pr?: Spacing | ResponsiveSpacing;
|
||||
px?: Spacing | ResponsiveSpacing;
|
||||
py?: Spacing | ResponsiveSpacing;
|
||||
|
||||
// Sizing
|
||||
w?: string | number | ResponsiveValue<string | number>;
|
||||
h?: string | number | ResponsiveValue<string | number>;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
width?: string | number | ResponsiveValue<string | number>;
|
||||
height?: string | number | ResponsiveValue<string | number>;
|
||||
maxWidth?: string | ResponsiveValue<string>;
|
||||
minWidth?: string | ResponsiveValue<string>;
|
||||
maxHeight?: string | ResponsiveValue<string>;
|
||||
@@ -62,6 +76,11 @@ export interface BoxProps<T extends ElementType> {
|
||||
fullWidth?: boolean;
|
||||
fullHeight?: boolean;
|
||||
aspectRatio?: string;
|
||||
|
||||
// Aliases
|
||||
w?: string | number | ResponsiveValue<string | number>;
|
||||
h?: string | number | ResponsiveValue<string | number>;
|
||||
|
||||
// Display
|
||||
display?: 'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none' | string | ResponsiveValue<'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none' | string>;
|
||||
center?: boolean;
|
||||
@@ -80,28 +99,6 @@ export interface BoxProps<T extends ElementType> {
|
||||
insetY?: string | number;
|
||||
insetX?: string | number;
|
||||
zIndex?: number;
|
||||
// Basic Styling
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full' | string | boolean;
|
||||
border?: boolean | string;
|
||||
borderTop?: boolean | string;
|
||||
borderBottom?: boolean | string;
|
||||
borderLeft?: boolean | string;
|
||||
borderRight?: boolean | string;
|
||||
borderWidth?: string | number;
|
||||
borderStyle?: 'solid' | 'dashed' | 'dotted' | 'none' | string;
|
||||
borderColor?: string;
|
||||
borderOpacity?: number;
|
||||
bg?: string;
|
||||
backgroundColor?: string;
|
||||
backgroundImage?: string;
|
||||
backgroundSize?: string;
|
||||
backgroundPosition?: string;
|
||||
bgOpacity?: number;
|
||||
color?: string;
|
||||
shadow?: string;
|
||||
opacity?: number;
|
||||
blur?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | string;
|
||||
pointerEvents?: 'auto' | 'none' | string;
|
||||
// Flex/Grid Item props
|
||||
flex?: number | string;
|
||||
flexShrink?: number;
|
||||
@@ -113,20 +110,41 @@ export interface BoxProps<T extends ElementType> {
|
||||
alignSelf?: 'auto' | 'start' | 'end' | 'center' | 'stretch' | 'baseline';
|
||||
gap?: number | string | ResponsiveValue<number | string>;
|
||||
gridCols?: number | ResponsiveValue<number>;
|
||||
responsiveGridCols?: number | ResponsiveValue<number>;
|
||||
colSpan?: number | ResponsiveValue<number>;
|
||||
responsiveColSpan?: number | ResponsiveValue<number>;
|
||||
order?: number | string | ResponsiveValue<number | string>;
|
||||
// Transform
|
||||
transform?: string | boolean;
|
||||
translate?: string;
|
||||
translateX?: string;
|
||||
translateY?: string;
|
||||
// Animation (Framer Motion support)
|
||||
initial?: any;
|
||||
animate?: any;
|
||||
exit?: any;
|
||||
// Interaction
|
||||
onClick?: React.MouseEventHandler<any>;
|
||||
onMouseEnter?: React.MouseEventHandler<any>;
|
||||
onMouseLeave?: React.MouseEventHandler<any>;
|
||||
id?: string;
|
||||
role?: React.AriaRole;
|
||||
tabIndex?: number;
|
||||
// Internal use only
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
borderTop?: string | boolean;
|
||||
borderBottom?: string | boolean;
|
||||
borderLeft?: string | boolean;
|
||||
borderRight?: string | boolean;
|
||||
bg?: string;
|
||||
rounded?: string | boolean;
|
||||
borderColor?: string;
|
||||
border?: string | boolean;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
transition?: any;
|
||||
hoverBg?: string;
|
||||
group?: boolean;
|
||||
groupHoverOpacity?: number;
|
||||
groupHoverBorderColor?: string;
|
||||
groupHoverWidth?: string;
|
||||
animate?: any;
|
||||
blur?: string;
|
||||
pointerEvents?: string;
|
||||
bgOpacity?: number;
|
||||
borderWidth?: string | number;
|
||||
borderStyle?: string;
|
||||
initial?: any;
|
||||
variants?: any;
|
||||
whileHover?: any;
|
||||
whileTap?: any;
|
||||
@@ -135,39 +153,14 @@ export interface BoxProps<T extends ElementType> {
|
||||
whileInView?: any;
|
||||
viewport?: any;
|
||||
custom?: any;
|
||||
// Interaction
|
||||
group?: boolean;
|
||||
groupHoverTextColor?: string;
|
||||
groupHoverScale?: boolean;
|
||||
groupHoverOpacity?: number;
|
||||
groupHoverBorderColor?: string;
|
||||
hoverBorderColor?: string;
|
||||
hoverBg?: string;
|
||||
hoverTextColor?: string;
|
||||
hoverScale?: boolean | number;
|
||||
clickable?: boolean;
|
||||
// Events
|
||||
onMouseEnter?: React.MouseEventHandler<any>;
|
||||
onMouseLeave?: React.MouseEventHandler<any>;
|
||||
onClick?: React.MouseEventHandler<any>;
|
||||
onMouseDown?: React.MouseEventHandler<any>;
|
||||
onMouseUp?: React.MouseEventHandler<any>;
|
||||
onMouseMove?: React.MouseEventHandler<any>;
|
||||
onKeyDown?: React.KeyboardEventHandler<any>;
|
||||
onBlur?: React.FocusEventHandler<any>;
|
||||
onSubmit?: React.FormEventHandler<any>;
|
||||
onScroll?: React.UIEventHandler<any>;
|
||||
style?: React.CSSProperties;
|
||||
id?: string;
|
||||
role?: React.AriaRole;
|
||||
tabIndex?: number;
|
||||
// Other
|
||||
type?: 'button' | 'submit' | 'reset' | string;
|
||||
disabled?: boolean;
|
||||
exit?: any;
|
||||
translateX?: string;
|
||||
translateY?: string;
|
||||
translate?: string;
|
||||
cursor?: string;
|
||||
fontSize?: string | ResponsiveValue<string>;
|
||||
weight?: string;
|
||||
fontWeight?: string | number;
|
||||
weight?: string | number;
|
||||
letterSpacing?: string;
|
||||
lineHeight?: string | number;
|
||||
font?: string;
|
||||
@@ -176,19 +169,15 @@ export interface BoxProps<T extends ElementType> {
|
||||
truncate?: boolean;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
draggable?: boolean;
|
||||
draggable?: boolean | string;
|
||||
min?: string | number;
|
||||
max?: string | number;
|
||||
step?: string | number;
|
||||
value?: string | number;
|
||||
onChange?: React.ChangeEventHandler<any>;
|
||||
onError?: React.ReactEventHandler<any>;
|
||||
placeholder?: string;
|
||||
title?: string;
|
||||
padding?: Spacing | ResponsiveSpacing;
|
||||
paddingLeft?: Spacing | ResponsiveSpacing;
|
||||
paddingRight?: Spacing | ResponsiveSpacing;
|
||||
paddingTop?: Spacing | ResponsiveSpacing;
|
||||
paddingBottom?: Spacing | ResponsiveSpacing;
|
||||
size?: string | number | ResponsiveValue<string | number>;
|
||||
accept?: string;
|
||||
autoPlay?: boolean;
|
||||
@@ -196,16 +185,46 @@ export interface BoxProps<T extends ElementType> {
|
||||
muted?: boolean;
|
||||
playsInline?: boolean;
|
||||
objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down';
|
||||
type?: string;
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
onSubmit?: React.FormEventHandler<any>;
|
||||
onBlur?: React.FocusEventHandler<any>;
|
||||
onKeyDown?: React.KeyboardEventHandler<any>;
|
||||
onMouseDown?: React.MouseEventHandler<any>;
|
||||
onMouseUp?: React.MouseEventHandler<any>;
|
||||
onMouseMove?: React.MouseEventHandler<any>;
|
||||
onScroll?: React.UIEventHandler<any>;
|
||||
responsiveColSpan?: number | ResponsiveValue<number>;
|
||||
responsiveGridCols?: number | ResponsiveValue<number>;
|
||||
clickable?: boolean;
|
||||
hoverScale?: boolean | number;
|
||||
hoverBorderColor?: string;
|
||||
hoverTextColor?: string;
|
||||
groupHoverScale?: boolean;
|
||||
groupHoverTextColor?: string;
|
||||
shadow?: string;
|
||||
transform?: boolean | string;
|
||||
lineClamp?: number;
|
||||
fill?: string | boolean;
|
||||
viewBox?: string;
|
||||
stroke?: string;
|
||||
strokeWidth?: string | number;
|
||||
backgroundSize?: string;
|
||||
backgroundPosition?: string;
|
||||
backgroundImage?: string;
|
||||
}
|
||||
|
||||
export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
{
|
||||
as,
|
||||
children,
|
||||
className = '',
|
||||
margin, marginTop, marginBottom, marginLeft, marginRight, marginX, marginY,
|
||||
padding, paddingTop, paddingBottom, paddingLeft, paddingRight, paddingX, paddingY,
|
||||
m, mt, mb, ml, mr, mx, my,
|
||||
p, pt, pb, pl, pr, px, py,
|
||||
w, h, width, height,
|
||||
width, height,
|
||||
w, h,
|
||||
maxWidth, minWidth, maxHeight, minHeight,
|
||||
fullWidth, fullHeight,
|
||||
aspectRatio,
|
||||
@@ -218,27 +237,6 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
top, right, bottom, left,
|
||||
inset, insetY, insetX,
|
||||
zIndex,
|
||||
rounded,
|
||||
border,
|
||||
borderTop,
|
||||
borderBottom,
|
||||
borderLeft,
|
||||
borderRight,
|
||||
borderWidth,
|
||||
borderStyle,
|
||||
borderColor,
|
||||
borderOpacity,
|
||||
bg,
|
||||
backgroundColor,
|
||||
backgroundImage,
|
||||
backgroundSize,
|
||||
backgroundPosition,
|
||||
bgOpacity,
|
||||
color,
|
||||
shadow,
|
||||
opacity,
|
||||
blur,
|
||||
pointerEvents,
|
||||
flex,
|
||||
flexShrink,
|
||||
flexGrow,
|
||||
@@ -249,18 +247,39 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
alignSelf,
|
||||
gap,
|
||||
gridCols,
|
||||
responsiveGridCols,
|
||||
colSpan,
|
||||
responsiveColSpan,
|
||||
order,
|
||||
transform,
|
||||
translate,
|
||||
translateX,
|
||||
translateY,
|
||||
initial,
|
||||
animate,
|
||||
exit,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
id,
|
||||
role,
|
||||
tabIndex,
|
||||
style: styleProp,
|
||||
className,
|
||||
borderTop,
|
||||
borderBottom,
|
||||
borderLeft,
|
||||
borderRight,
|
||||
bg,
|
||||
rounded,
|
||||
borderColor,
|
||||
border,
|
||||
color,
|
||||
opacity,
|
||||
transition,
|
||||
hoverBg,
|
||||
group,
|
||||
groupHoverOpacity,
|
||||
groupHoverBorderColor,
|
||||
groupHoverWidth,
|
||||
animate,
|
||||
blur,
|
||||
pointerEvents,
|
||||
bgOpacity,
|
||||
borderWidth,
|
||||
borderStyle,
|
||||
initial,
|
||||
variants,
|
||||
whileHover,
|
||||
whileTap,
|
||||
@@ -269,36 +288,14 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
whileInView,
|
||||
viewport,
|
||||
custom,
|
||||
group,
|
||||
groupHoverTextColor,
|
||||
groupHoverScale,
|
||||
groupHoverOpacity,
|
||||
groupHoverBorderColor,
|
||||
hoverBorderColor,
|
||||
hoverBg,
|
||||
hoverTextColor,
|
||||
hoverScale,
|
||||
clickable,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onClick,
|
||||
onMouseDown,
|
||||
onMouseUp,
|
||||
onMouseMove,
|
||||
onKeyDown,
|
||||
onBlur,
|
||||
onSubmit,
|
||||
onScroll,
|
||||
style: styleProp,
|
||||
id,
|
||||
role,
|
||||
tabIndex,
|
||||
type,
|
||||
disabled,
|
||||
exit,
|
||||
translateX,
|
||||
translateY,
|
||||
translate,
|
||||
cursor,
|
||||
fontSize,
|
||||
weight,
|
||||
fontWeight,
|
||||
weight,
|
||||
letterSpacing,
|
||||
lineHeight,
|
||||
font,
|
||||
@@ -313,13 +310,9 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
step,
|
||||
value,
|
||||
onChange,
|
||||
onError,
|
||||
placeholder,
|
||||
title,
|
||||
padding,
|
||||
paddingLeft,
|
||||
paddingRight,
|
||||
paddingTop,
|
||||
paddingBottom,
|
||||
size,
|
||||
accept,
|
||||
autoPlay,
|
||||
@@ -327,8 +320,36 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
muted,
|
||||
playsInline,
|
||||
objectFit,
|
||||
type,
|
||||
checked,
|
||||
disabled,
|
||||
onSubmit,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
onMouseDown,
|
||||
onMouseUp,
|
||||
onMouseMove,
|
||||
onScroll,
|
||||
responsiveColSpan,
|
||||
responsiveGridCols,
|
||||
clickable,
|
||||
hoverScale,
|
||||
hoverBorderColor,
|
||||
hoverTextColor,
|
||||
groupHoverScale,
|
||||
groupHoverTextColor,
|
||||
shadow,
|
||||
transform,
|
||||
lineClamp,
|
||||
fill,
|
||||
viewBox,
|
||||
stroke,
|
||||
strokeWidth,
|
||||
backgroundSize,
|
||||
backgroundPosition,
|
||||
backgroundImage,
|
||||
...props
|
||||
}: BoxProps<T> & ComponentPropsWithoutRef<T>,
|
||||
}: BoxProps<T>,
|
||||
ref: ForwardedRef<HTMLElement>
|
||||
) => {
|
||||
const Tag = (as as ElementType) || 'div';
|
||||
@@ -371,22 +392,22 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
};
|
||||
|
||||
const classes = [
|
||||
getSpacingClass('m', m),
|
||||
getSpacingClass('mt', mt),
|
||||
getSpacingClass('mb', mb),
|
||||
getSpacingClass('ml', ml),
|
||||
getSpacingClass('mr', mr),
|
||||
getSpacingClass('mx', mx),
|
||||
getSpacingClass('my', my),
|
||||
getSpacingClass('p', p || padding),
|
||||
getSpacingClass('pt', pt || paddingTop),
|
||||
getSpacingClass('pb', pb || paddingBottom),
|
||||
getSpacingClass('pl', pl || paddingLeft),
|
||||
getSpacingClass('pr', pr || paddingRight),
|
||||
getSpacingClass('px', px),
|
||||
getSpacingClass('py', py),
|
||||
fullWidth ? 'w-full' : getResponsiveClasses('w', w),
|
||||
fullHeight ? 'h-full' : getResponsiveClasses('h', h),
|
||||
getSpacingClass('m', margin || m),
|
||||
getSpacingClass('mt', marginTop || mt),
|
||||
getSpacingClass('mb', marginBottom || mb),
|
||||
getSpacingClass('ml', marginLeft || ml),
|
||||
getSpacingClass('mr', marginRight || mr),
|
||||
getSpacingClass('mx', marginX || mx),
|
||||
getSpacingClass('my', marginY || my),
|
||||
getSpacingClass('p', padding || p),
|
||||
getSpacingClass('pt', paddingTop || pt),
|
||||
getSpacingClass('pb', paddingBottom || pb),
|
||||
getSpacingClass('pl', paddingLeft || pl),
|
||||
getSpacingClass('pr', paddingRight || pr),
|
||||
getSpacingClass('px', paddingX || px),
|
||||
getSpacingClass('py', paddingY || py),
|
||||
fullWidth ? 'w-full' : getResponsiveClasses('w', width || w),
|
||||
fullHeight ? 'h-full' : getResponsiveClasses('h', height || h),
|
||||
getResponsiveClasses('max-w', maxWidth),
|
||||
getResponsiveClasses('min-w', minWidth),
|
||||
getResponsiveClasses('max-h', maxHeight),
|
||||
@@ -407,23 +428,6 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
insetY !== undefined ? `inset-y-${insetY}` : '',
|
||||
insetX !== undefined ? `inset-x-${insetX}` : '',
|
||||
zIndex !== undefined ? `z-${zIndex}` : '',
|
||||
rounded === true ? 'rounded' : (rounded === false ? 'rounded-none' : (typeof rounded === 'string' ? (rounded.includes('-') ? rounded : `rounded-${rounded}`) : '')),
|
||||
border === true ? 'border' : (typeof border === 'string' ? (border === 'none' ? 'border-none' : border) : ''),
|
||||
borderTop === true ? 'border-t' : (typeof borderTop === 'string' ? borderTop : ''),
|
||||
borderBottom === true ? 'border-b' : (typeof borderBottom === 'string' ? borderBottom : ''),
|
||||
borderLeft === true ? 'border-l' : (typeof borderLeft === 'string' ? borderLeft : ''),
|
||||
borderRight === true ? 'border-r' : (typeof borderRight === 'string' ? borderRight : ''),
|
||||
borderStyle ? `border-${borderStyle}` : '',
|
||||
borderColor ? borderColor : '',
|
||||
borderOpacity !== undefined ? `border-opacity-${borderOpacity * 100}` : '',
|
||||
bg ? bg : '',
|
||||
backgroundColor ? backgroundColor : '',
|
||||
bgOpacity !== undefined ? `bg-opacity-${bgOpacity * 100}` : '',
|
||||
color ? color : '',
|
||||
shadow ? shadow : '',
|
||||
opacity !== undefined ? `opacity-${opacity * 100}` : '',
|
||||
blur ? (blur === 'none' ? 'blur-none' : `blur-${blur}`) : '',
|
||||
pointerEvents ? `pointer-events-${pointerEvents}` : '',
|
||||
flex !== undefined ? `flex-${flex}` : '',
|
||||
flexShrink !== undefined ? `flex-shrink-${flexShrink}` : '',
|
||||
flexGrow !== undefined ? `flex-grow-${flexGrow}` : '',
|
||||
@@ -436,21 +440,14 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
getResponsiveClasses('grid-cols', gridCols || responsiveGridCols),
|
||||
getResponsiveClasses('col-span', colSpan || responsiveColSpan),
|
||||
getResponsiveClasses('order', order),
|
||||
getResponsiveClasses('text', fontSize),
|
||||
group ? 'group' : '',
|
||||
groupHoverTextColor ? `group-hover:text-${groupHoverTextColor}` : '',
|
||||
groupHoverScale ? 'group-hover:scale-105 transition-transform' : '',
|
||||
groupHoverOpacity !== undefined ? `group-hover:opacity-${groupHoverOpacity * 100}` : '',
|
||||
groupHoverBorderColor ? `group-hover:border-${groupHoverBorderColor}` : '',
|
||||
hoverBorderColor ? `hover:border-${hoverBorderColor}` : '',
|
||||
hoverBg ? `hover:bg-${hoverBg}` : '',
|
||||
hoverTextColor ? `hover:text-${hoverTextColor}` : '',
|
||||
hoverScale === true ? 'hover:scale-105 transition-transform' : (typeof hoverScale === 'number' ? `hover:scale-${hoverScale} transition-transform` : ''),
|
||||
clickable ? 'cursor-pointer active:opacity-80 transition-all' : '',
|
||||
ring ? `ring-${ring}` : '',
|
||||
animate === 'spin' ? 'animate-spin' : (animate === 'pulse' ? 'animate-pulse' : ''),
|
||||
blur ? `blur-${blur}` : '',
|
||||
pointerEvents ? `pointer-events-${pointerEvents}` : '',
|
||||
hideScrollbar ? 'scrollbar-hide' : '',
|
||||
truncate ? 'truncate' : '',
|
||||
transform === true ? 'transform' : (transform === false ? 'transform-none' : ''),
|
||||
clickable ? 'cursor-pointer' : '',
|
||||
lineClamp ? `line-clamp-${lineClamp}` : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
@@ -466,23 +463,27 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
...(typeof right === 'string' || typeof right === 'number' ? { right } : {}),
|
||||
...(typeof bottom === 'string' || typeof bottom === 'number' ? { bottom } : {}),
|
||||
...(typeof left === 'string' || typeof left === 'number' ? { left } : {}),
|
||||
...(borderWidth !== undefined ? { borderWidth } : {}),
|
||||
...(typeof transform === 'string' ? { transform } : {}),
|
||||
...(translate ? { translate } : {}),
|
||||
...(translateX ? { transform: `translateX(${translateX})` } : {}),
|
||||
...(translateY ? { transform: `translateY(${translateY})` } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
...(fontSize && typeof fontSize === 'string' && !fontSize.includes(':') ? { fontSize } : {}),
|
||||
...(weight ? { fontWeight: weight } : {}),
|
||||
...(typeof borderTop === 'string' ? { borderTop } : (borderTop === true ? { borderTop: '1px solid var(--ui-color-border-default)' } : {})),
|
||||
...(typeof borderBottom === 'string' ? { borderBottom } : (borderBottom === true ? { borderBottom: '1px solid var(--ui-color-border-default)' } : {})),
|
||||
...(typeof borderLeft === 'string' ? { borderLeft } : (borderLeft === true ? { borderLeft: '1px solid var(--ui-color-border-default)' } : {})),
|
||||
...(typeof borderRight === 'string' ? { borderRight } : (borderRight === true ? { borderRight: '1px solid var(--ui-color-border-default)' } : {})),
|
||||
...(bg ? { background: bg.startsWith('bg-') ? undefined : bg } : {}),
|
||||
...(rounded === true ? { borderRadius: 'var(--ui-radius-md)' } : (typeof rounded === 'string' ? { borderRadius: rounded.includes('rem') || rounded.includes('px') ? rounded : `var(--ui-radius-${rounded})` } : {})),
|
||||
...(borderColor ? { borderColor: borderColor.startsWith('border-') ? undefined : borderColor } : {}),
|
||||
...(border === true ? { border: '1px solid var(--ui-color-border-default)' } : (typeof border === 'string' ? { border } : {})),
|
||||
...(color ? { color: color.startsWith('text-') ? undefined : color } : {}),
|
||||
...(opacity !== undefined ? { opacity } : {}),
|
||||
...(fontSize ? (typeof fontSize === 'string' ? { fontSize } : {}) : {}),
|
||||
...(fontWeight ? { fontWeight } : {}),
|
||||
...(letterSpacing ? { letterSpacing } : {}),
|
||||
...(lineHeight ? { lineHeight } : {}),
|
||||
...(font ? { fontFamily: font } : {}),
|
||||
...(typeof size === 'string' || typeof size === 'number' ? { width: size, height: size } : {}),
|
||||
...(backgroundImage ? { backgroundImage } : {}),
|
||||
...(weight ? { fontWeight: weight } : {}),
|
||||
...(shadow ? { boxShadow: shadow.startsWith('shadow-') ? undefined : shadow } : {}),
|
||||
...(transform === true ? { transform: 'auto' } : (typeof transform === 'string' ? { transform } : {})),
|
||||
...(typeof fill === 'string' ? { fill } : (fill === true ? { fill: 'currentColor' } : {})),
|
||||
...(stroke ? { stroke } : {}),
|
||||
...(strokeWidth ? { strokeWidth } : {}),
|
||||
...(backgroundSize ? { backgroundSize } : {}),
|
||||
...(backgroundPosition ? { backgroundPosition } : {}),
|
||||
...(objectFit ? { objectFit } : {}),
|
||||
...(backgroundImage ? { backgroundImage } : {}),
|
||||
...(styleProp || {})
|
||||
};
|
||||
|
||||
@@ -491,24 +492,24 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
ref={ref as React.ForwardedRef<HTMLElement>}
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseMove={onMouseMove}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={onBlur}
|
||||
onSubmit={onSubmit}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onScroll={onScroll}
|
||||
style={style}
|
||||
onError={onError}
|
||||
style={Object.keys(style).length > 0 ? style : undefined}
|
||||
id={id}
|
||||
role={role}
|
||||
tabIndex={tabIndex}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={draggable}
|
||||
draggable={draggable as any}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
@@ -520,6 +521,7 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
loop={loop}
|
||||
muted={muted}
|
||||
playsInline={playsInline}
|
||||
viewBox={viewBox}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -13,57 +13,25 @@ import { Box, BoxProps, ResponsiveValue } from './Box';
|
||||
* If you need a more specific layout, create a new component in apps/website/components.
|
||||
*/
|
||||
|
||||
export interface GridProps<T extends ElementType = 'div'> extends Omit<BoxProps<T>, 'children'> {
|
||||
export interface GridProps<T extends ElementType = 'div'> extends BoxProps<T> {
|
||||
children?: ReactNode;
|
||||
cols?: 1 | 2 | 3 | 4 | 5 | 6 | 12;
|
||||
mdCols?: 1 | 2 | 3 | 4 | 5 | 6 | 12;
|
||||
lgCols?: 1 | 2 | 3 | 4 | 5 | 6 | 12;
|
||||
gap?: 0 | 1 | 2 | 3 | 4 | 6 | 8 | 12 | 16;
|
||||
className?: string;
|
||||
columns?: number | ResponsiveValue<number>;
|
||||
gap?: number | string | ResponsiveValue<number | string>;
|
||||
}
|
||||
|
||||
export function Grid<T extends ElementType = 'div'>({
|
||||
children,
|
||||
cols = 1,
|
||||
mdCols,
|
||||
lgCols,
|
||||
columns = 1,
|
||||
gap = 4,
|
||||
className = '',
|
||||
...props
|
||||
}: GridProps<T>) {
|
||||
const colClasses: Record<number, string> = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-3',
|
||||
4: 'grid-cols-2 md:grid-cols-4',
|
||||
5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5',
|
||||
6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6',
|
||||
12: 'grid-cols-12'
|
||||
};
|
||||
|
||||
const gapClasses: Record<number, string> = {
|
||||
0: 'gap-0',
|
||||
1: 'gap-1',
|
||||
2: 'gap-2',
|
||||
3: 'gap-3',
|
||||
4: 'gap-4',
|
||||
6: 'gap-6',
|
||||
8: 'gap-8',
|
||||
12: 'gap-12',
|
||||
16: 'gap-16'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
'grid',
|
||||
colClasses[cols] || 'grid-cols-1',
|
||||
mdCols ? `md:grid-cols-${mdCols}` : '',
|
||||
lgCols ? `lg:grid-cols-${lgCols}` : '',
|
||||
gapClasses[gap] || 'gap-4',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Box className={classes} {...props}>
|
||||
<Box
|
||||
display="grid"
|
||||
gridCols={columns}
|
||||
gap={gap}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,27 @@
|
||||
import React, { ElementType } from 'react';
|
||||
import { Box, BoxProps } from './Box';
|
||||
import React, { ReactNode, ElementType } from 'react';
|
||||
import { Box, BoxProps, ResponsiveValue } from './Box';
|
||||
|
||||
/**
|
||||
* WARNING: DO NOT VIOLATE THE PURPOSE OF THIS PRIMITIVE.
|
||||
*
|
||||
* GridItem is for items inside a Grid container.
|
||||
*
|
||||
* - DO NOT add positioning props (absolute, top, zIndex).
|
||||
* - DO NOT add background/border props.
|
||||
*
|
||||
* If you need a more specific layout, create a new component in apps/website/components.
|
||||
*/
|
||||
|
||||
export interface GridItemProps<T extends ElementType = 'div'> extends Omit<BoxProps<T>, 'children'> {
|
||||
children?: React.ReactNode;
|
||||
colSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
mdSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
lgSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
className?: string;
|
||||
export interface GridItemProps<T extends ElementType = 'div'> extends BoxProps<T> {
|
||||
children?: ReactNode;
|
||||
colSpan?: number | ResponsiveValue<number>;
|
||||
rowSpan?: number | ResponsiveValue<number>;
|
||||
lgSpan?: number; // Alias for colSpan.lg
|
||||
}
|
||||
|
||||
export function GridItem<T extends ElementType = 'div'>({ children, colSpan, mdSpan, lgSpan, className = '', ...props }: GridItemProps<T>) {
|
||||
const spanClasses = [
|
||||
colSpan ? `col-span-${colSpan}` : '',
|
||||
mdSpan ? `md:col-span-${mdSpan}` : '',
|
||||
lgSpan ? `lg:col-span-${lgSpan}` : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
export function GridItem<T extends ElementType = 'div'>({
|
||||
children,
|
||||
colSpan,
|
||||
rowSpan,
|
||||
lgSpan,
|
||||
...props
|
||||
}: GridItemProps<T>) {
|
||||
const actualColSpan = lgSpan ? { base: colSpan as any, lg: lgSpan } : colSpan;
|
||||
|
||||
return (
|
||||
<Box className={spanClasses} {...props}>
|
||||
<Box
|
||||
colSpan={actualColSpan as any}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -13,20 +13,10 @@ import { Box, BoxProps, ResponsiveValue } from './Box';
|
||||
* If you need a more specific layout, create a new component in apps/website/components.
|
||||
*/
|
||||
|
||||
interface ResponsiveGap {
|
||||
base?: number;
|
||||
sm?: number;
|
||||
md?: number;
|
||||
lg?: number;
|
||||
xl?: number;
|
||||
}
|
||||
|
||||
export interface StackProps<T extends ElementType> extends Omit<BoxProps<T>, 'children'> {
|
||||
export interface StackProps<T extends ElementType> extends BoxProps<T> {
|
||||
as?: T;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
direction?: 'row' | 'col' | { base?: 'row' | 'col'; md?: 'row' | 'col'; lg?: 'row' | 'col' };
|
||||
gap?: number | string | ResponsiveGap;
|
||||
direction?: 'row' | 'col' | ResponsiveValue<'row' | 'col'>;
|
||||
align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline' | ResponsiveValue<'start' | 'center' | 'end' | 'stretch' | 'baseline'>;
|
||||
justify?: 'start' | 'center' | 'end' | 'between' | 'around' | ResponsiveValue<'start' | 'center' | 'end' | 'between' | 'around'>;
|
||||
wrap?: boolean;
|
||||
@@ -35,7 +25,6 @@ export interface StackProps<T extends ElementType> extends Omit<BoxProps<T>, 'ch
|
||||
export const Stack = forwardRef(<T extends ElementType = 'div'>(
|
||||
{
|
||||
children,
|
||||
className = '',
|
||||
direction = 'col',
|
||||
gap = 4,
|
||||
align,
|
||||
@@ -46,91 +35,16 @@ export const Stack = forwardRef(<T extends ElementType = 'div'>(
|
||||
}: StackProps<T>,
|
||||
ref: ForwardedRef<HTMLElement>
|
||||
) => {
|
||||
const gapClasses: Record<number, string> = {
|
||||
0: 'gap-0',
|
||||
1: 'gap-1',
|
||||
2: 'gap-2',
|
||||
3: 'gap-3',
|
||||
4: 'gap-4',
|
||||
5: 'gap-5',
|
||||
6: 'gap-6',
|
||||
8: 'gap-8',
|
||||
10: 'gap-10',
|
||||
12: 'gap-12',
|
||||
16: 'gap-16'
|
||||
};
|
||||
|
||||
const getGapClasses = (value: number | string | ResponsiveGap | undefined) => {
|
||||
if (value === undefined) return '';
|
||||
if (typeof value === 'object') {
|
||||
const classes = [];
|
||||
if (value.base !== undefined) classes.push(typeof value.base === 'number' ? gapClasses[value.base] : `gap-${value.base}`);
|
||||
if (value.sm !== undefined) classes.push(typeof value.sm === 'number' ? `sm:${gapClasses[value.sm]}` : `sm:gap-${value.sm}`);
|
||||
if (value.md !== undefined) classes.push(typeof value.md === 'number' ? `md:${gapClasses[value.md]}` : `md:gap-${value.md}`);
|
||||
if (value.lg !== undefined) classes.push(typeof value.lg === 'number' ? `lg:${gapClasses[value.lg]}` : `lg:gap-${value.lg}`);
|
||||
if (value.xl !== undefined) classes.push(typeof value.xl === 'number' ? `xl:${gapClasses[value.xl]}` : `xl:gap-${value.xl}`);
|
||||
return classes.join(' ');
|
||||
}
|
||||
if (typeof value === 'number') return gapClasses[value];
|
||||
return `gap-${value}`;
|
||||
};
|
||||
|
||||
const classes = [
|
||||
'flex',
|
||||
typeof direction === 'string'
|
||||
? (direction === 'col' ? 'flex-col' : 'flex-row')
|
||||
: [
|
||||
direction.base === 'col' ? 'flex-col' : (direction.base === 'row' ? 'flex-row' : ''),
|
||||
direction.md === 'col' ? 'md:flex-col' : (direction.md === 'row' ? 'md:flex-row' : ''),
|
||||
direction.lg === 'col' ? 'lg:flex-col' : (direction.lg === 'row' ? 'lg:flex-row' : ''),
|
||||
].filter(Boolean).join(' '),
|
||||
getGapClasses(gap) || 'gap-4',
|
||||
wrap ? 'flex-wrap' : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const getAlignItemsClass = (value: StackProps<ElementType>['align']) => {
|
||||
if (!value) return '';
|
||||
const map: Record<string, string> = { start: 'items-start', center: 'items-center', end: 'items-end', stretch: 'items-stretch', baseline: 'items-baseline' };
|
||||
if (typeof value === 'object') {
|
||||
const classes = [];
|
||||
if (value.base) classes.push(map[value.base]);
|
||||
if (value.sm) classes.push(`sm:${map[value.sm]}`);
|
||||
if (value.md) classes.push(`md:${map[value.md]}`);
|
||||
if (value.lg) classes.push(`lg:${map[value.lg]}`);
|
||||
if (value.xl) classes.push(`xl:${map[value.xl]}`);
|
||||
if (value['2xl']) classes.push(`2xl:${map[value['2xl']]}`);
|
||||
return classes.join(' ');
|
||||
}
|
||||
return map[value];
|
||||
};
|
||||
|
||||
const getJustifyContentClass = (value: StackProps<ElementType>['justify']) => {
|
||||
if (!value) return '';
|
||||
const map: Record<string, string> = { start: 'justify-start', center: 'justify-center', end: 'justify-end', between: 'justify-between', around: 'justify-around' };
|
||||
if (typeof value === 'object') {
|
||||
const classes = [];
|
||||
if (value.base) classes.push(map[value.base]);
|
||||
if (value.sm) classes.push(`sm:${map[value.sm]}`);
|
||||
if (value.md) classes.push(`md:${map[value.md]}`);
|
||||
if (value.lg) classes.push(`lg:${map[value.lg]}`);
|
||||
if (value.xl) classes.push(`xl:${map[value.xl]}`);
|
||||
if (value['2xl']) classes.push(`2xl:${map[value['2xl']]}`);
|
||||
return classes.join(' ');
|
||||
}
|
||||
return map[value];
|
||||
};
|
||||
|
||||
const layoutClasses = [
|
||||
getAlignItemsClass(align),
|
||||
getJustifyContentClass(justify)
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Box
|
||||
as={as}
|
||||
ref={ref}
|
||||
className={`${classes} ${layoutClasses}`}
|
||||
display="flex"
|
||||
flexDirection={direction}
|
||||
gap={gap}
|
||||
alignItems={align}
|
||||
justifyContent={justify}
|
||||
flexWrap={wrap ? 'wrap' : 'nowrap'}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { ReactNode, ElementType, ComponentPropsWithoutRef, forwardRef, ForwardedRef } from 'react';
|
||||
import React, { ReactNode, ElementType, forwardRef, ForwardedRef } from 'react';
|
||||
import { Box, BoxProps } from './Box';
|
||||
import { ThemeRadii, ThemeShadows } from '../theme/Theme';
|
||||
|
||||
/**
|
||||
* WARNING: DO NOT VIOLATE THE PURPOSE OF THIS PRIMITIVE.
|
||||
@@ -12,15 +13,12 @@ import { Box, BoxProps } from './Box';
|
||||
* If you need a more specific layout, create a new component in apps/website/components.
|
||||
*/
|
||||
|
||||
export interface SurfaceProps<T extends ElementType = 'div'> extends Omit<BoxProps<T>, 'children' | 'padding'> {
|
||||
export interface SurfaceProps<T extends ElementType = 'div'> extends BoxProps<T> {
|
||||
as?: T;
|
||||
children?: ReactNode;
|
||||
variant?: 'default' | 'muted' | 'dark' | 'glass' | 'gradient-blue' | 'gradient-gold' | 'gradient-purple' | 'gradient-green' | 'discord' | 'discord-inner';
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full' | string | boolean;
|
||||
border?: boolean | string;
|
||||
padding?: number;
|
||||
className?: string;
|
||||
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'discord' | string;
|
||||
variant?: 'default' | 'dark' | 'muted' | 'glass' | 'discord' | 'gradient-blue' | 'gradient-gold' | 'gradient-purple' | 'gradient-green' | 'discord-inner' | 'outline';
|
||||
rounded?: keyof ThemeRadii | 'none';
|
||||
shadow?: keyof ThemeShadows | 'none';
|
||||
}
|
||||
|
||||
export const Surface = forwardRef(<T extends ElementType = 'div'>(
|
||||
@@ -29,69 +27,52 @@ export const Surface = forwardRef(<T extends ElementType = 'div'>(
|
||||
children,
|
||||
variant = 'default',
|
||||
rounded = 'none',
|
||||
border = false,
|
||||
padding = 0,
|
||||
className = '',
|
||||
shadow = 'none',
|
||||
...props
|
||||
}: SurfaceProps<T> & ComponentPropsWithoutRef<T>,
|
||||
}: SurfaceProps<T>,
|
||||
ref: ForwardedRef<HTMLElement>
|
||||
) => {
|
||||
const variantClasses: Record<string, string> = {
|
||||
default: 'bg-panel-gray',
|
||||
muted: 'bg-panel-gray/40',
|
||||
dark: 'bg-graphite-black',
|
||||
glass: 'bg-graphite-black/60 backdrop-blur-md',
|
||||
'gradient-blue': 'bg-gradient-to-br from-primary-accent/10 via-panel-gray/80 to-graphite-black',
|
||||
'gradient-gold': 'bg-gradient-to-br from-warning-amber/10 via-panel-gray/80 to-graphite-black',
|
||||
'gradient-purple': 'bg-gradient-to-br from-purple-600/10 via-panel-gray/80 to-graphite-black',
|
||||
'gradient-green': 'bg-gradient-to-br from-success-green/10 via-panel-gray/80 to-graphite-black',
|
||||
'discord': 'bg-gradient-to-b from-graphite-black to-panel-gray',
|
||||
'discord-inner': 'bg-gradient-to-br from-panel-gray via-graphite-black to-panel-gray'
|
||||
const variantStyles: Record<string, React.CSSProperties> = {
|
||||
default: { backgroundColor: 'var(--ui-color-bg-surface)' },
|
||||
dark: { backgroundColor: 'var(--ui-color-bg-base)' },
|
||||
muted: { backgroundColor: 'var(--ui-color-bg-surface-muted)' },
|
||||
glass: {
|
||||
backgroundColor: 'rgba(20, 22, 25, 0.6)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)'
|
||||
},
|
||||
discord: {
|
||||
background: 'linear-gradient(to bottom, var(--ui-color-bg-base), var(--ui-color-bg-surface))'
|
||||
},
|
||||
'discord-inner': {
|
||||
background: 'linear-gradient(to br, var(--ui-color-bg-surface), var(--ui-color-bg-base), var(--ui-color-bg-surface))'
|
||||
},
|
||||
'gradient-blue': {
|
||||
background: 'linear-gradient(to br, rgba(25, 140, 255, 0.1), var(--ui-color-bg-surface), var(--ui-color-bg-base))'
|
||||
},
|
||||
'gradient-gold': {
|
||||
background: 'linear-gradient(to br, rgba(255, 190, 77, 0.1), var(--ui-color-bg-surface), var(--ui-color-bg-base))'
|
||||
},
|
||||
'gradient-purple': {
|
||||
background: 'linear-gradient(to br, rgba(147, 51, 234, 0.1), var(--ui-color-bg-surface), var(--ui-color-bg-base))'
|
||||
},
|
||||
'gradient-green': {
|
||||
background: 'linear-gradient(to br, rgba(111, 227, 122, 0.1), var(--ui-color-bg-surface), var(--ui-color-bg-base))'
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid var(--ui-color-border-default)'
|
||||
}
|
||||
};
|
||||
|
||||
const shadowClasses: Record<string, string> = {
|
||||
none: '',
|
||||
sm: 'shadow-sm',
|
||||
md: 'shadow-md',
|
||||
lg: 'shadow-lg',
|
||||
xl: 'shadow-xl',
|
||||
discord: 'shadow-[0_0_80px_rgba(88,101,242,0.15)]'
|
||||
const style: React.CSSProperties = {
|
||||
...variantStyles[variant],
|
||||
borderRadius: rounded !== 'none' ? `var(--ui-radius-${String(rounded)})` : undefined,
|
||||
boxShadow: shadow !== 'none' ? `var(--ui-shadow-${String(shadow)})` : undefined,
|
||||
};
|
||||
|
||||
const roundedClasses: Record<string, string> = {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl',
|
||||
'2xl': 'rounded-2xl',
|
||||
full: 'rounded-full'
|
||||
};
|
||||
|
||||
const paddingClasses: Record<number, string> = {
|
||||
0: 'p-0',
|
||||
1: 'p-1',
|
||||
2: 'p-2',
|
||||
3: 'p-3',
|
||||
4: 'p-4',
|
||||
6: 'p-6',
|
||||
8: 'p-8',
|
||||
10: 'p-10',
|
||||
12: 'p-12'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
variantClasses[variant],
|
||||
typeof rounded === 'string' && roundedClasses[rounded] ? roundedClasses[rounded] : '',
|
||||
border ? 'border border-border-gray' : '',
|
||||
paddingClasses[padding] || 'p-0',
|
||||
shadowClasses[shadow],
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Box as={as} ref={ref} className={classes} {...props}>
|
||||
<Box as={as} ref={ref} {...(props as any)} style={style}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -47,6 +47,43 @@ export interface ThemeTypography {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ThemeSpacing {
|
||||
0: string;
|
||||
0.5: string;
|
||||
1: string;
|
||||
1.5: string;
|
||||
2: string;
|
||||
2.5: string;
|
||||
3: string;
|
||||
3.5: string;
|
||||
4: string;
|
||||
5: string;
|
||||
6: string;
|
||||
7: string;
|
||||
8: string;
|
||||
9: string;
|
||||
10: string;
|
||||
11: string;
|
||||
12: string;
|
||||
14: string;
|
||||
16: string;
|
||||
20: string;
|
||||
24: string;
|
||||
28: string;
|
||||
32: string;
|
||||
36: string;
|
||||
40: string;
|
||||
44: string;
|
||||
48: string;
|
||||
52: string;
|
||||
56: string;
|
||||
60: string;
|
||||
64: string;
|
||||
72: string;
|
||||
80: string;
|
||||
96: string;
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -54,4 +91,5 @@ export interface Theme {
|
||||
radii: ThemeRadii;
|
||||
shadows: ThemeShadows;
|
||||
typography: ThemeTypography;
|
||||
spacing: ThemeSpacing;
|
||||
}
|
||||
|
||||
@@ -48,4 +48,40 @@ export const defaultTheme: Theme = {
|
||||
mono: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
},
|
||||
},
|
||||
spacing: {
|
||||
0: '0',
|
||||
0.5: '0.125rem',
|
||||
1: '0.25rem',
|
||||
1.5: '0.375rem',
|
||||
2: '0.5rem',
|
||||
2.5: '0.625rem',
|
||||
3: '0.75rem',
|
||||
3.5: '0.875rem',
|
||||
4: '1rem',
|
||||
5: '1.25rem',
|
||||
6: '1.5rem',
|
||||
7: '1.75rem',
|
||||
8: '2rem',
|
||||
9: '2.25rem',
|
||||
10: '2.5rem',
|
||||
11: '2.75rem',
|
||||
12: '3rem',
|
||||
14: '3.5rem',
|
||||
16: '4rem',
|
||||
20: '5rem',
|
||||
24: '6rem',
|
||||
28: '7rem',
|
||||
32: '8rem',
|
||||
36: '9rem',
|
||||
40: '10rem',
|
||||
44: '11rem',
|
||||
48: '12rem',
|
||||
52: '13rem',
|
||||
56: '14rem',
|
||||
60: '15rem',
|
||||
64: '16rem',
|
||||
72: '18rem',
|
||||
80: '20rem',
|
||||
96: '24rem',
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user