ADD new frontend
This commit is contained in:
55
frontend/src/components/ui/Button/.!76623!Button.test.tsx
Normal file
55
frontend/src/components/ui/Button/.!76623!Button.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import Button from './Button';
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders correctly with default props', () => {
|
||||
render(<Button>Test Button</Button>);
|
||||
const button = screen.getByRole('button', { name: /test button/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('bg-color-primary'); // default variant
|
||||
});
|
||||
|
||||
it('applies the correct variant classes', () => {
|
||||
const { rerender } = render(<Button variant="secondary">Secondary</Button>);
|
||||
let button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-color-secondary');
|
||||
|
||||
rerender(<Button variant="danger">Danger</Button>);
|
||||
button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-color-error');
|
||||
|
||||
rerender(<Button variant="outline">Outline</Button>);
|
||||
button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-transparent');
|
||||
expect(button).toHaveClass('border-color-primary');
|
||||
});
|
||||
|
||||
it('applies the correct size classes', () => {
|
||||
const { rerender } = render(<Button size="xs">Extra Small</Button>);
|
||||
let button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('px-2', 'py-1', 'text-xs');
|
||||
|
||||
rerender(<Button size="lg">Large</Button>);
|
||||
button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('px-6', 'py-2.5', 'text-base');
|
||||
});
|
||||
|
||||
it('handles loading state correctly', () => {
|
||||
render(
|
||||
<Button isLoading loadingText="Loading...">
|
||||
Submit
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveTextContent('Loading...');
|
||||
|
||||
// Check if loading spinner is present
|
||||
const spinner = button.querySelector('svg');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
expect(spinner).toHaveClass('animate-spin');
|
||||
});
|
||||
|
||||
it('renders icons correctly', () => {
|
||||
46
frontend/src/components/ui/Button/.!76624!Button.stories.tsx
Normal file
46
frontend/src/components/ui/Button/.!76624!Button.stories.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Button from './Button';
|
||||
import {
|
||||
ShoppingCartIcon,
|
||||
HeartIcon,
|
||||
ArrowRightIcon,
|
||||
PlusIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'UI/Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'outline', 'ghost', 'danger', 'success', 'warning'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['xs', 'sm', 'md', 'lg', 'xl'],
|
||||
},
|
||||
isLoading: {
|
||||
control: 'boolean',
|
||||
},
|
||||
isFullWidth: {
|
||||
control: 'boolean',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
children: {
|
||||
control: 'text',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
170
frontend/src/components/ui/Button/Button.stories.tsx
Normal file
170
frontend/src/components/ui/Button/Button.stories.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Button from './Button';
|
||||
import {
|
||||
ShoppingCartIcon,
|
||||
HeartIcon,
|
||||
ArrowRightIcon,
|
||||
PlusIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'UI/Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'outline', 'ghost', 'danger', 'success', 'warning'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['xs', 'sm', 'md', 'lg', 'xl'],
|
||||
},
|
||||
isLoading: {
|
||||
control: 'boolean',
|
||||
},
|
||||
isFullWidth: {
|
||||
control: 'boolean',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
children: {
|
||||
control: 'text',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
children: 'Bot<6F>n Principal',
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
variant: 'secondary',
|
||||
children: 'Bot<6F>n Secundario',
|
||||
},
|
||||
};
|
||||
|
||||
export const Outline: Story = {
|
||||
args: {
|
||||
variant: 'outline',
|
||||
children: 'Bot<6F>n Contorno',
|
||||
},
|
||||
};
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: {
|
||||
variant: 'ghost',
|
||||
children: 'Bot<6F>n Fantasma',
|
||||
},
|
||||
};
|
||||
|
||||
export const Danger: Story = {
|
||||
args: {
|
||||
variant: 'danger',
|
||||
children: 'Eliminar',
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
variant: 'success',
|
||||
children: 'Guardar',
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
variant: 'warning',
|
||||
children: 'Advertencia',
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
isLoading: true,
|
||||
children: 'Cargando...',
|
||||
loadingText: 'Procesando...',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLeftIcon: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
leftIcon: <ShoppingCartIcon className="w-4 h-4" />,
|
||||
children: 'A<>adir al Carrito',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRightIcon: Story = {
|
||||
args: {
|
||||
variant: 'outline',
|
||||
rightIcon: <ArrowRightIcon className="w-4 h-4" />,
|
||||
children: 'Continuar',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithBothIcons: Story = {
|
||||
args: {
|
||||
variant: 'secondary',
|
||||
leftIcon: <HeartIcon className="w-4 h-4" />,
|
||||
rightIcon: <PlusIcon className="w-4 h-4" />,
|
||||
children: 'Me Gusta',
|
||||
},
|
||||
};
|
||||
|
||||
export const FullWidth: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
isFullWidth: true,
|
||||
children: 'Bot<6F>n de Ancho Completo',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
disabled: true,
|
||||
children: 'Bot<6F>n Deshabilitado',
|
||||
},
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<Button size="xs" variant="primary">Extra Peque<EFBFBD>o</Button>
|
||||
<Button size="sm" variant="primary">Peque<EFBFBD>o</Button>
|
||||
<Button size="md" variant="primary">Mediano</Button>
|
||||
<Button size="lg" variant="primary">Grande</Button>
|
||||
<Button size="xl" variant="primary">Extra Grande</Button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="primary">Principal</Button>
|
||||
<Button variant="secondary">Secundario</Button>
|
||||
<Button variant="outline">Contorno</Button>
|
||||
<Button variant="ghost">Fantasma</Button>
|
||||
<Button variant="danger">Peligro</Button>
|
||||
<Button variant="success"><EFBFBD>xito</Button>
|
||||
<Button variant="warning">Advertencia</Button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
162
frontend/src/components/ui/Button/Button.test.tsx
Normal file
162
frontend/src/components/ui/Button/Button.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import Button from './Button';
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders correctly with default props', () => {
|
||||
render(<Button>Test Button</Button>);
|
||||
const button = screen.getByRole('button', { name: /test button/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('bg-color-primary'); // default variant
|
||||
});
|
||||
|
||||
it('applies the correct variant classes', () => {
|
||||
const { rerender } = render(<Button variant="secondary">Secondary</Button>);
|
||||
let button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-color-secondary');
|
||||
|
||||
rerender(<Button variant="danger">Danger</Button>);
|
||||
button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-color-error');
|
||||
|
||||
rerender(<Button variant="outline">Outline</Button>);
|
||||
button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-transparent');
|
||||
expect(button).toHaveClass('border-color-primary');
|
||||
});
|
||||
|
||||
it('applies the correct size classes', () => {
|
||||
const { rerender } = render(<Button size="xs">Extra Small</Button>);
|
||||
let button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('px-2', 'py-1', 'text-xs');
|
||||
|
||||
rerender(<Button size="lg">Large</Button>);
|
||||
button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('px-6', 'py-2.5', 'text-base');
|
||||
});
|
||||
|
||||
it('handles loading state correctly', () => {
|
||||
render(
|
||||
<Button isLoading loadingText="Loading...">
|
||||
Submit
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveTextContent('Loading...');
|
||||
|
||||
// Check if loading spinner is present
|
||||
const spinner = button.querySelector('svg');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
expect(spinner).toHaveClass('animate-spin');
|
||||
});
|
||||
|
||||
it('renders icons correctly', () => {
|
||||
const leftIcon = <span data-testid="left-icon"><EFBFBD></span>;
|
||||
const rightIcon = <span data-testid="right-icon"><EFBFBD></span>;
|
||||
|
||||
render(
|
||||
<Button leftIcon={leftIcon} rightIcon={rightIcon}>
|
||||
With Icons
|
||||
</Button>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('left-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('right-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render icons when loading', () => {
|
||||
const leftIcon = <span data-testid="left-icon"><EFBFBD></span>;
|
||||
const rightIcon = <span data-testid="right-icon"><EFBFBD></span>;
|
||||
|
||||
render(
|
||||
<Button isLoading leftIcon={leftIcon} rightIcon={rightIcon}>
|
||||
Loading
|
||||
</Button>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('left-icon')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('right-icon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies full width class when isFullWidth is true', () => {
|
||||
render(<Button isFullWidth>Full Width</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('w-full');
|
||||
});
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
render(<Button disabled>Disabled Button</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('is disabled when loading', () => {
|
||||
render(<Button isLoading>Loading Button</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('handles click events', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Button onClick={handleClick}>Click Me</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call onClick when disabled', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<Button disabled onClick={handleClick}>
|
||||
Disabled
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onClick when loading', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<Button isLoading onClick={handleClick}>
|
||||
Loading
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards ref correctly', () => {
|
||||
const ref = { current: null };
|
||||
render(<Button ref={ref}>Ref Test</Button>);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Button className="custom-class">Custom Class</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('passes through other props', () => {
|
||||
render(
|
||||
<Button type="submit" data-testid="submit-btn">
|
||||
Submit
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByTestId('submit-btn');
|
||||
expect(button).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
});
|
||||
145
frontend/src/components/ui/Button/Button.tsx
Normal file
145
frontend/src/components/ui/Button/Button.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { forwardRef, ButtonHTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
isLoading?: boolean;
|
||||
isFullWidth?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
isFullWidth = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
loadingText,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) => {
|
||||
const baseClasses = [
|
||||
'inline-flex items-center justify-center font-medium',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'border rounded-lg'
|
||||
];
|
||||
|
||||
const variantClasses = {
|
||||
primary: [
|
||||
'bg-[var(--color-primary)] text-[var(--text-inverse)] border-[var(--color-primary)]',
|
||||
'hover:bg-[var(--color-primary-dark)] hover:border-[var(--color-primary-dark)]',
|
||||
'focus:ring-[var(--color-primary)]/20',
|
||||
'active:bg-[var(--color-primary-dark)]'
|
||||
],
|
||||
secondary: [
|
||||
'bg-[var(--color-secondary)] text-[var(--text-inverse)] border-[var(--color-secondary)]',
|
||||
'hover:bg-[var(--color-secondary-dark)] hover:border-[var(--color-secondary-dark)]',
|
||||
'focus:ring-[var(--color-secondary)]/20',
|
||||
'active:bg-[var(--color-secondary-dark)]'
|
||||
],
|
||||
outline: [
|
||||
'bg-transparent text-[var(--color-primary)] border-[var(--color-primary)]',
|
||||
'hover:bg-[var(--color-primary)] hover:text-[var(--text-inverse)]',
|
||||
'focus:ring-[var(--color-primary)]/20',
|
||||
'active:bg-[var(--color-primary-dark)] active:border-[var(--color-primary-dark)]'
|
||||
],
|
||||
ghost: [
|
||||
'bg-transparent text-[var(--color-primary)] border-transparent',
|
||||
'hover:bg-[var(--color-primary)]/10 hover:text-[var(--color-primary-dark)]',
|
||||
'focus:ring-[var(--color-primary)]/20',
|
||||
'active:bg-[var(--color-primary)]/20'
|
||||
],
|
||||
danger: [
|
||||
'bg-[var(--color-error)] text-[var(--text-inverse)] border-[var(--color-error)]',
|
||||
'hover:bg-[var(--color-error-dark)] hover:border-[var(--color-error-dark)]',
|
||||
'focus:ring-[var(--color-error)]/20',
|
||||
'active:bg-[var(--color-error-dark)]'
|
||||
],
|
||||
success: [
|
||||
'bg-[var(--color-success)] text-[var(--text-inverse)] border-[var(--color-success)]',
|
||||
'hover:bg-[var(--color-success-dark)] hover:border-[var(--color-success-dark)]',
|
||||
'focus:ring-[var(--color-success)]/20',
|
||||
'active:bg-[var(--color-success-dark)]'
|
||||
],
|
||||
warning: [
|
||||
'bg-[var(--color-warning)] text-[var(--text-inverse)] border-[var(--color-warning)]',
|
||||
'hover:bg-[var(--color-warning-dark)] hover:border-[var(--color-warning-dark)]',
|
||||
'focus:ring-[var(--color-warning)]/20',
|
||||
'active:bg-[var(--color-warning-dark)]'
|
||||
]
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'px-2 py-1 text-xs gap-1 min-h-6',
|
||||
sm: 'px-3 py-1.5 text-sm gap-1.5 min-h-8',
|
||||
md: 'px-4 py-2 text-sm gap-2 min-h-10',
|
||||
lg: 'px-6 py-2.5 text-base gap-2 min-h-12',
|
||||
xl: 'px-8 py-3 text-lg gap-3 min-h-14'
|
||||
};
|
||||
|
||||
const loadingSpinner = (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const classes = clsx(
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
{
|
||||
'w-full': isFullWidth,
|
||||
'cursor-wait': isLoading
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={classes}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && loadingSpinner}
|
||||
{!isLoading && leftIcon && (
|
||||
<span className="flex-shrink-0">{leftIcon}</span>
|
||||
)}
|
||||
<span>
|
||||
{isLoading && loadingText ? loadingText : children}
|
||||
</span>
|
||||
{!isLoading && rightIcon && (
|
||||
<span className="flex-shrink-0">{rightIcon}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export default Button;
|
||||
3
frontend/src/components/ui/Button/index.ts
Normal file
3
frontend/src/components/ui/Button/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './Button';
|
||||
export { default as Button } from './Button';
|
||||
export type { ButtonProps } from './Button';
|
||||
Reference in New Issue
Block a user