ADD new frontend

This commit is contained in:
Urtzi Alfaro
2025-08-28 10:41:04 +02:00
parent 9c247a5f99
commit 0fd273cfce
492 changed files with 114979 additions and 1632 deletions

View 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', () => {

View 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',

View 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>
),
};

View 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');
});
});

View 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;

View File

@@ -0,0 +1,3 @@
export { default } from './Button';
export { default as Button } from './Button';
export type { ButtonProps } from './Button';