feat(ux-ui): add Switch, ThemeToggle, Skeleton, LanguageSelector atoms
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6568b9bfed
commit
04c61d0a71
@ -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;
|
||||
1
src/shared/components/atoms/LanguageSelector/index.ts
Normal file
1
src/shared/components/atoms/LanguageSelector/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './LanguageSelector';
|
||||
47
src/shared/components/atoms/Skeleton/Skeleton.tsx
Normal file
47
src/shared/components/atoms/Skeleton/Skeleton.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
74
src/shared/components/atoms/Skeleton/SkeletonAvatar.tsx
Normal file
74
src/shared/components/atoms/Skeleton/SkeletonAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/shared/components/atoms/Skeleton/SkeletonCard.tsx
Normal file
77
src/shared/components/atoms/Skeleton/SkeletonCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/shared/components/atoms/Skeleton/SkeletonTable.tsx
Normal file
116
src/shared/components/atoms/Skeleton/SkeletonTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/shared/components/atoms/Skeleton/SkeletonText.tsx
Normal file
37
src/shared/components/atoms/Skeleton/SkeletonText.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/shared/components/atoms/Skeleton/index.ts
Normal file
5
src/shared/components/atoms/Skeleton/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './Skeleton';
|
||||
export * from './SkeletonText';
|
||||
export * from './SkeletonCard';
|
||||
export * from './SkeletonTable';
|
||||
export * from './SkeletonAvatar';
|
||||
134
src/shared/components/atoms/Switch/Switch.tsx
Normal file
134
src/shared/components/atoms/Switch/Switch.tsx
Normal 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';
|
||||
1
src/shared/components/atoms/Switch/index.ts
Normal file
1
src/shared/components/atoms/Switch/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Switch';
|
||||
136
src/shared/components/atoms/ThemeToggle/ThemeToggle.tsx
Normal file
136
src/shared/components/atoms/ThemeToggle/ThemeToggle.tsx
Normal 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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/shared/components/atoms/ThemeToggle/index.ts
Normal file
1
src/shared/components/atoms/ThemeToggle/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './ThemeToggle';
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user