feat(ux-ui): add Switch, ThemeToggle, Skeleton, LanguageSelector atoms

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 19:26:02 -06:00
parent 6568b9bfed
commit 04c61d0a71
13 changed files with 801 additions and 0 deletions

View File

@ -0,0 +1,169 @@
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Globe, Check } from 'lucide-react';
import { cn } from '@utils/cn';
import { useTranslation } from '@shared/i18n/useTranslation';
import type { Language } from '@shared/i18n';
interface LanguageOption {
value: Language;
label: string;
flag: string;
}
const defaultLanguageOption: LanguageOption = { value: 'es', label: 'Espanol', flag: '🇪🇸' };
const languageOptions: LanguageOption[] = [
defaultLanguageOption,
{ value: 'en', label: 'English', flag: '🇺🇸' },
];
export interface LanguageSelectorProps {
/** Additional CSS classes */
className?: string;
/** Show flag icons */
showFlags?: boolean;
/** Compact mode (icon only) */
compact?: boolean;
}
export function LanguageSelector({
className,
showFlags = true,
compact = true,
}: LanguageSelectorProps) {
const { language, changeLanguage, t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, right: 0 });
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const currentOption = languageOptions.find((opt) => opt.value === language) ?? defaultLanguageOption;
useEffect(() => {
if (isOpen && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + 4,
right: window.innerWidth - rect.right,
});
}
}, [isOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current &&
!menuRef.current.contains(event.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
const handleSelect = (value: Language) => {
changeLanguage(value);
setIsOpen(false);
};
const menu = (
<AnimatePresence>
{isOpen && (
<motion.div
ref={menuRef}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
className="fixed z-50 min-w-[160px] rounded-lg border bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
style={{
top: position.top,
right: position.right,
}}
role="listbox"
aria-label={t('language.select')}
>
{languageOptions.map((option) => {
const isSelected = language === option.value;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => handleSelect(option.value)}
className={cn(
'flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm transition-colors',
isSelected
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
<span className="flex items-center gap-2">
{showFlags && <span className="text-base">{option.flag}</span>}
<span>{option.label}</span>
</span>
{isSelected && <Check className="h-4 w-4" />}
</button>
);
})}
</motion.div>
)}
</AnimatePresence>
);
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex items-center gap-2 rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700',
className
)}
aria-label={t('language.select')}
aria-haspopup="listbox"
aria-expanded={isOpen}
title={t('language.select')}
>
{compact ? (
<>
{showFlags ? (
<span className="text-lg">{currentOption.flag}</span>
) : (
<Globe className="h-5 w-5 text-gray-500 dark:text-gray-400" />
)}
</>
) : (
<>
{showFlags && <span className="text-lg">{currentOption.flag}</span>}
<span className="text-sm text-gray-700 dark:text-gray-300">{currentOption.label}</span>
</>
)}
</button>
{createPortal(menu, document.body)}
</>
);
}
export default LanguageSelector;

View File

@ -0,0 +1 @@
export * from './LanguageSelector';

View File

@ -0,0 +1,47 @@
import { cn } from '@utils/cn';
export interface SkeletonProps {
width?: string | number;
height?: string | number;
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full';
animate?: boolean;
className?: string;
}
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
};
export function Skeleton({
width,
height,
rounded = 'md',
animate = true,
className,
}: SkeletonProps) {
const style: React.CSSProperties = {};
if (width !== undefined) {
style.width = typeof width === 'number' ? `${width}px` : width;
}
if (height !== undefined) {
style.height = typeof height === 'number' ? `${height}px` : height;
}
return (
<div
className={cn(
'bg-gray-200 dark:bg-gray-700',
roundedClasses[rounded],
animate && 'animate-pulse',
className
)}
style={style}
/>
);
}

View File

@ -0,0 +1,74 @@
import { cn } from '@utils/cn';
import { Skeleton } from './Skeleton';
export interface SkeletonAvatarProps {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
animate?: boolean;
className?: string;
}
const sizeClasses = {
xs: 'h-6 w-6',
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-12 w-12',
xl: 'h-16 w-16',
'2xl': 'h-20 w-20',
};
export function SkeletonAvatar({
size = 'md',
animate = true,
className,
}: SkeletonAvatarProps) {
return (
<Skeleton
rounded="full"
animate={animate}
className={cn(sizeClasses[size], className)}
/>
);
}
// Avatar with text skeleton (common pattern)
export interface SkeletonAvatarWithTextProps {
size?: 'sm' | 'md' | 'lg';
showSubtitle?: boolean;
animate?: boolean;
className?: string;
}
export function SkeletonAvatarWithText({
size = 'md',
showSubtitle = true,
animate = true,
className,
}: SkeletonAvatarWithTextProps) {
const textSizes = {
sm: { title: '0.875rem', subtitle: '0.75rem' },
md: { title: '1rem', subtitle: '0.875rem' },
lg: { title: '1.125rem', subtitle: '1rem' },
};
return (
<div className={cn('flex items-center gap-3', className)}>
<SkeletonAvatar size={size} animate={animate} />
<div className="flex-1 space-y-1.5">
<Skeleton
width="60%"
height={textSizes[size].title}
rounded="sm"
animate={animate}
/>
{showSubtitle && (
<Skeleton
width="40%"
height={textSizes[size].subtitle}
rounded="sm"
animate={animate}
/>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,77 @@
import { cn } from '@utils/cn';
import { Skeleton } from './Skeleton';
import { SkeletonText } from './SkeletonText';
export interface SkeletonCardProps {
showImage?: boolean;
imageHeight?: string | number;
showTitle?: boolean;
showDescription?: boolean;
descriptionLines?: number;
showFooter?: boolean;
animate?: boolean;
className?: string;
}
export function SkeletonCard({
showImage = true,
imageHeight = 160,
showTitle = true,
showDescription = true,
descriptionLines = 2,
showFooter = false,
animate = true,
className,
}: SkeletonCardProps) {
return (
<div
className={cn(
'overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{showImage && (
<Skeleton
width="100%"
height={imageHeight}
rounded="none"
animate={animate}
/>
)}
<div className="p-4 space-y-3">
{showTitle && (
<Skeleton
width="70%"
height="1.25rem"
rounded="sm"
animate={animate}
/>
)}
{showDescription && (
<SkeletonText
lines={descriptionLines}
lastLineWidth="80%"
lineHeight="0.875rem"
animate={animate}
/>
)}
{showFooter && (
<div className="flex items-center justify-between pt-2">
<Skeleton
width="5rem"
height="2rem"
rounded="md"
animate={animate}
/>
<Skeleton
width="5rem"
height="2rem"
rounded="md"
animate={animate}
/>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,116 @@
import { cn } from '@utils/cn';
import { Skeleton } from './Skeleton';
export interface SkeletonTableProps {
rows?: number;
columns?: number;
showHeader?: boolean;
animate?: boolean;
className?: string;
}
export function SkeletonTable({
rows = 5,
columns = 4,
showHeader = true,
animate = true,
className,
}: SkeletonTableProps) {
return (
<div
className={cn(
'overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700',
className
)}
>
<table className="w-full">
{showHeader && (
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{Array.from({ length: columns }).map((_, colIndex) => (
<th key={colIndex} className="px-4 py-3">
<Skeleton
width="80%"
height="0.875rem"
rounded="sm"
animate={animate}
/>
</th>
))}
</tr>
</thead>
)}
<tbody className="bg-white dark:bg-gray-900">
{Array.from({ length: rows }).map((_, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-200 dark:border-gray-700"
>
{Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="px-4 py-3">
<Skeleton
width={colIndex === 0 ? '90%' : '70%'}
height="1rem"
rounded="sm"
animate={animate}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
// Alternative: Simple row-based skeleton for lists
export interface SkeletonTableRowsProps {
rows?: number;
animate?: boolean;
className?: string;
}
export function SkeletonTableRows({
rows = 5,
animate = true,
className,
}: SkeletonTableRowsProps) {
return (
<div className={cn('space-y-2', className)}>
{Array.from({ length: rows }).map((_, index) => (
<div
key={index}
className="flex items-center gap-4 p-3 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
>
<Skeleton
width={40}
height={40}
rounded="full"
animate={animate}
/>
<div className="flex-1 space-y-2">
<Skeleton
width="60%"
height="1rem"
rounded="sm"
animate={animate}
/>
<Skeleton
width="40%"
height="0.75rem"
rounded="sm"
animate={animate}
/>
</div>
<Skeleton
width={80}
height="2rem"
rounded="md"
animate={animate}
/>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,37 @@
import { cn } from '@utils/cn';
import { Skeleton } from './Skeleton';
export interface SkeletonTextProps {
lines?: number;
lastLineWidth?: string;
lineHeight?: string;
gap?: string;
animate?: boolean;
className?: string;
}
export function SkeletonText({
lines = 3,
lastLineWidth = '60%',
lineHeight = '1rem',
gap = '0.75rem',
animate = true,
className,
}: SkeletonTextProps) {
return (
<div className={cn('flex flex-col', className)} style={{ gap }}>
{Array.from({ length: lines }).map((_, index) => {
const isLastLine = index === lines - 1;
return (
<Skeleton
key={index}
width={isLastLine ? lastLineWidth : '100%'}
height={lineHeight}
rounded="sm"
animate={animate}
/>
);
})}
</div>
);
}

View File

@ -0,0 +1,5 @@
export * from './Skeleton';
export * from './SkeletonText';
export * from './SkeletonCard';
export * from './SkeletonTable';
export * from './SkeletonAvatar';

View File

@ -0,0 +1,134 @@
import { forwardRef, type InputHTMLAttributes } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@utils/cn';
const switchVariants = cva(
'relative inline-flex shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
size: {
sm: 'h-4 w-7',
md: 'h-5 w-9',
lg: 'h-6 w-11',
},
checked: {
true: 'bg-primary-600',
false: 'bg-gray-200',
},
},
defaultVariants: {
size: 'md',
checked: false,
},
}
);
const thumbVariants = cva(
'pointer-events-none inline-block transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out',
{
variants: {
size: {
sm: 'h-3 w-3',
md: 'h-4 w-4',
lg: 'h-5 w-5',
},
checked: {
true: '',
false: 'translate-x-0',
},
},
compoundVariants: [
{ size: 'sm', checked: true, className: 'translate-x-3' },
{ size: 'md', checked: true, className: 'translate-x-4' },
{ size: 'lg', checked: true, className: 'translate-x-5' },
],
defaultVariants: {
size: 'md',
checked: false,
},
}
);
export interface SwitchProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'onChange'>,
Omit<VariantProps<typeof switchVariants>, 'checked'> {
checked: boolean;
onChange: (checked: boolean) => void;
label?: string;
}
export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
(
{
checked,
onChange,
disabled,
size,
label,
id,
name,
className,
...props
},
ref
) => {
const handleChange = () => {
if (!disabled) {
onChange(!checked);
}
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleChange();
}
};
const switchElement = (
<button
type="button"
role="switch"
aria-checked={checked}
aria-disabled={disabled}
disabled={disabled}
onClick={handleChange}
onKeyDown={handleKeyDown}
className={cn(switchVariants({ size, checked }), className)}
>
<span className={cn(thumbVariants({ size, checked }))} />
<input
ref={ref}
type="checkbox"
id={id}
name={name}
checked={checked}
onChange={() => onChange(!checked)}
disabled={disabled}
className="sr-only"
{...props}
/>
</button>
);
if (label) {
return (
<label className="inline-flex cursor-pointer items-center gap-2">
{switchElement}
<span
className={cn(
'text-sm font-medium text-gray-900',
disabled && 'opacity-50'
)}
>
{label}
</span>
</label>
);
}
return switchElement;
}
);
Switch.displayName = 'Switch';

View File

@ -0,0 +1 @@
export * from './Switch';

View File

@ -0,0 +1,136 @@
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Sun, Moon, Monitor } from 'lucide-react';
import { cn } from '@utils/cn';
import { useTheme } from '@hooks/useTheme';
import type { Theme } from '@shared/providers/ThemeProvider';
interface ThemeOption {
value: Theme;
label: string;
icon: typeof Sun;
}
const themeOptions: ThemeOption[] = [
{ value: 'light', label: 'Claro', icon: Sun },
{ value: 'dark', label: 'Oscuro', icon: Moon },
{ value: 'system', label: 'Sistema', icon: Monitor },
];
export interface ThemeToggleProps {
className?: string;
}
export function ThemeToggle({ className }: ThemeToggleProps) {
const { theme, resolvedTheme, setTheme } = useTheme();
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, right: 0 });
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const CurrentIcon = resolvedTheme === 'dark' ? Moon : Sun;
useEffect(() => {
if (isOpen && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + 4,
right: window.innerWidth - rect.right,
});
}
}, [isOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current &&
!menuRef.current.contains(event.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
const handleSelect = (value: Theme) => {
setTheme(value);
setIsOpen(false);
};
const menu = (
<AnimatePresence>
{isOpen && (
<motion.div
ref={menuRef}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
className="fixed z-50 min-w-[140px] rounded-lg border bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
style={{
top: position.top,
right: position.right,
}}
>
{themeOptions.map((option) => {
const Icon = option.icon;
const isSelected = theme === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => handleSelect(option.value)}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors',
isSelected
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
<Icon className="h-4 w-4" />
<span>{option.label}</span>
</button>
);
})}
</motion.div>
)}
</AnimatePresence>
);
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700',
className
)}
aria-label="Cambiar tema"
title="Cambiar tema"
>
<CurrentIcon className="h-5 w-5 text-gray-500 dark:text-gray-400" />
</button>
{createPortal(menu, document.body)}
</>
);
}

View File

@ -0,0 +1 @@
export * from './ThemeToggle';

View File

@ -5,3 +5,6 @@ export * from './Badge';
export * from './Spinner';
export * from './Avatar';
export * from './Tooltip';
export * from './ThemeToggle';
export * from './Skeleton';
export * from './LanguageSelector';