diff --git a/src/shared/components/atoms/LanguageSelector/LanguageSelector.tsx b/src/shared/components/atoms/LanguageSelector/LanguageSelector.tsx new file mode 100644 index 0000000..deeff49 --- /dev/null +++ b/src/shared/components/atoms/LanguageSelector/LanguageSelector.tsx @@ -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(null); + const menuRef = useRef(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 = ( + + {isOpen && ( + + {languageOptions.map((option) => { + const isSelected = language === option.value; + return ( + + ); + })} + + )} + + ); + + return ( + <> + + {createPortal(menu, document.body)} + + ); +} + +export default LanguageSelector; diff --git a/src/shared/components/atoms/LanguageSelector/index.ts b/src/shared/components/atoms/LanguageSelector/index.ts new file mode 100644 index 0000000..3151142 --- /dev/null +++ b/src/shared/components/atoms/LanguageSelector/index.ts @@ -0,0 +1 @@ +export * from './LanguageSelector'; diff --git a/src/shared/components/atoms/Skeleton/Skeleton.tsx b/src/shared/components/atoms/Skeleton/Skeleton.tsx new file mode 100644 index 0000000..24d56e3 --- /dev/null +++ b/src/shared/components/atoms/Skeleton/Skeleton.tsx @@ -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 ( +
+ ); +} diff --git a/src/shared/components/atoms/Skeleton/SkeletonAvatar.tsx b/src/shared/components/atoms/Skeleton/SkeletonAvatar.tsx new file mode 100644 index 0000000..8f6ee7e --- /dev/null +++ b/src/shared/components/atoms/Skeleton/SkeletonAvatar.tsx @@ -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 ( + + ); +} + +// 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 ( +
+ +
+ + {showSubtitle && ( + + )} +
+
+ ); +} diff --git a/src/shared/components/atoms/Skeleton/SkeletonCard.tsx b/src/shared/components/atoms/Skeleton/SkeletonCard.tsx new file mode 100644 index 0000000..a7b58fa --- /dev/null +++ b/src/shared/components/atoms/Skeleton/SkeletonCard.tsx @@ -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 ( +
+ {showImage && ( + + )} +
+ {showTitle && ( + + )} + {showDescription && ( + + )} + {showFooter && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/src/shared/components/atoms/Skeleton/SkeletonTable.tsx b/src/shared/components/atoms/Skeleton/SkeletonTable.tsx new file mode 100644 index 0000000..cfe9d43 --- /dev/null +++ b/src/shared/components/atoms/Skeleton/SkeletonTable.tsx @@ -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 ( +
+ + {showHeader && ( + + + {Array.from({ length: columns }).map((_, colIndex) => ( + + ))} + + + )} + + {Array.from({ length: rows }).map((_, rowIndex) => ( + + {Array.from({ length: columns }).map((_, colIndex) => ( + + ))} + + ))} + +
+ +
+ +
+
+ ); +} + +// 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 ( +
+ {Array.from({ length: rows }).map((_, index) => ( +
+ +
+ + +
+ +
+ ))} +
+ ); +} diff --git a/src/shared/components/atoms/Skeleton/SkeletonText.tsx b/src/shared/components/atoms/Skeleton/SkeletonText.tsx new file mode 100644 index 0000000..ee3df60 --- /dev/null +++ b/src/shared/components/atoms/Skeleton/SkeletonText.tsx @@ -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 ( +
+ {Array.from({ length: lines }).map((_, index) => { + const isLastLine = index === lines - 1; + return ( + + ); + })} +
+ ); +} diff --git a/src/shared/components/atoms/Skeleton/index.ts b/src/shared/components/atoms/Skeleton/index.ts new file mode 100644 index 0000000..22ca471 --- /dev/null +++ b/src/shared/components/atoms/Skeleton/index.ts @@ -0,0 +1,5 @@ +export * from './Skeleton'; +export * from './SkeletonText'; +export * from './SkeletonCard'; +export * from './SkeletonTable'; +export * from './SkeletonAvatar'; diff --git a/src/shared/components/atoms/Switch/Switch.tsx b/src/shared/components/atoms/Switch/Switch.tsx new file mode 100644 index 0000000..226cca5 --- /dev/null +++ b/src/shared/components/atoms/Switch/Switch.tsx @@ -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, 'size' | 'onChange'>, + Omit, 'checked'> { + checked: boolean; + onChange: (checked: boolean) => void; + label?: string; +} + +export const Switch = forwardRef( + ( + { + 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 = ( + + ); + + if (label) { + return ( + + ); + } + + return switchElement; + } +); + +Switch.displayName = 'Switch'; diff --git a/src/shared/components/atoms/Switch/index.ts b/src/shared/components/atoms/Switch/index.ts new file mode 100644 index 0000000..1b19c1d --- /dev/null +++ b/src/shared/components/atoms/Switch/index.ts @@ -0,0 +1 @@ +export * from './Switch'; diff --git a/src/shared/components/atoms/ThemeToggle/ThemeToggle.tsx b/src/shared/components/atoms/ThemeToggle/ThemeToggle.tsx new file mode 100644 index 0000000..f0f5192 --- /dev/null +++ b/src/shared/components/atoms/ThemeToggle/ThemeToggle.tsx @@ -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(null); + const menuRef = useRef(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 = ( + + {isOpen && ( + + {themeOptions.map((option) => { + const Icon = option.icon; + const isSelected = theme === option.value; + return ( + + ); + })} + + )} + + ); + + return ( + <> + + {createPortal(menu, document.body)} + + ); +} diff --git a/src/shared/components/atoms/ThemeToggle/index.ts b/src/shared/components/atoms/ThemeToggle/index.ts new file mode 100644 index 0000000..879e513 --- /dev/null +++ b/src/shared/components/atoms/ThemeToggle/index.ts @@ -0,0 +1 @@ +export * from './ThemeToggle'; diff --git a/src/shared/components/atoms/index.ts b/src/shared/components/atoms/index.ts index 9331dd7..6d0e872 100644 --- a/src/shared/components/atoms/index.ts +++ b/src/shared/components/atoms/index.ts @@ -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';