Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/components/Button/Button.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { ButtonVariant, ButtonSize, ButtonShape } from './Button.types';

export const BUTTON_VARIANTS: Record<ButtonVariant, ButtonVariant> = {
primary: 'primary',
secondary: 'secondary',
success: 'success',
error: 'error',
} as const;

export const BUTTON_SIZES: Record<ButtonSize, ButtonSize> = {
small: 'small',
medium: 'medium',
large: 'large',
} as const;

export const BUTTON_SHAPES: Record<ButtonShape, ButtonShape> = {
rounded: 'rounded',
square: 'square',
pill: 'pill',
} as const;

export const SIZE_CONFIGS = {
small: { height: 32, padding: '6px 16px', fontSize: '0.8125rem', minWidth: 64 },
medium: { height: 40, padding: '8px 22px', fontSize: '0.875rem', minWidth: 64 },
large: { height: 48, padding: '10px 28px', fontSize: '0.9375rem', minWidth: 64 },
} as const;

export const SHAPE_CONFIGS = {
rounded: { borderRadius: '8px' },
square: { borderRadius: '4px' },
pill: { borderRadius: '24px' },
} as const;

export const DEFAULT_PROPS = {
variant: 'primary' as ButtonVariant,
size: 'medium' as ButtonSize,
shape: 'rounded' as ButtonShape,
loading: false,
fullWidth: false,
disabled: false,
} as const;

45 changes: 45 additions & 0 deletions src/components/Button/Button.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
import { BUTTON_VARIANTS, BUTTON_SIZES, BUTTON_SHAPES } from './Button.constants';

const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Button con múltiples variantes, tamaños y formas sobre MUI.',
},
},
},
argTypes: {
variant: { control: 'select', options: Object.keys(BUTTON_VARIANTS) },
size: { control: 'select', options: Object.keys(BUTTON_SIZES) },
shape: { control: 'select', options: Object.keys(BUTTON_SHAPES) },
loading: { control: 'boolean' },
disabled: { control: 'boolean' },
fullWidth: { control: 'boolean' },
children: { control: 'text' },
onClick: { action: 'clicked' },
},
args: {
children: 'Click me',
variant: 'primary',
size: 'medium',
shape: 'rounded',
loading: false,
disabled: false,
fullWidth: false,
},
};
export default meta;
type Story = StoryObj<typeof Button>;

export const Playground: Story = {};

export const Primary: Story = { args: { variant: 'primary', children: 'Primary' } };
export const Secondary: Story = { args: { variant: 'secondary', children: 'Secondary' } };
export const Success: Story = { args: { variant: 'success', children: 'Success' } };
export const Error: Story = { args: { variant: 'error', children: 'Error' } };

105 changes: 105 additions & 0 deletions src/components/Button/Button.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { styled } from '@mui/material/styles';
import { Button as MuiButton, CircularProgress } from '@mui/material';
import type { StyledButtonProps } from './Button.types';
import { SIZE_CONFIGS, SHAPE_CONFIGS } from './Button.constants';
import { blueScale, redScale, greenScale, greyScale } from '../../style-library/types/theme.helpers';

// Map variant → palette
const VARIANT_COLORS = {
primary: { main: blueScale[600], light: blueScale[50], hover: blueScale[700], active: blueScale[800] },
secondary: { main: greyScale[600], light: greyScale[50], hover: greyScale[700], active: greyScale[800] },
success: { main: greenScale[600], light: greenScale[50], hover: greenScale[700], active: greenScale[800] },
error: { main: redScale[600], light: redScale[50], hover: redScale[700], active: redScale[800] },
} as const;

export const StyledButton = styled(MuiButton, {
shouldForwardProp: (prop) => !prop.toString().startsWith('$'),
})<StyledButtonProps>(({ theme, $variant, $size, $shape, $loading, $fullWidth }) => {
const colors = VARIANT_COLORS[$variant];
const size = SIZE_CONFIGS[$size];
const shape = SHAPE_CONFIGS[$shape];

return {
height: size.height,
minWidth: $fullWidth ? '100%' : size.minWidth,
padding: size.padding,
fontSize: size.fontSize,
fontWeight: 500,
textTransform: 'none',
borderRadius: shape.borderRadius,
border: 'none',
position: 'relative',
overflow: 'hidden',

// contained
backgroundColor: colors.main,
color: '#fff',
boxShadow: theme.shadows[2],
cursor: $loading ? 'default' : 'pointer',

'&:hover': {
backgroundColor: colors.hover,
boxShadow: theme.shadows[4],
transform: 'translateY(-1px)',
},
'&:focus': {
outline: 'none',
boxShadow: `${theme.shadows[4]}, 0 0 0 3px ${colors.light}`,
},
'&:active': {
backgroundColor: colors.active,
transform: 'translateY(0)',
boxShadow: theme.shadows[1],
},

'&:disabled': {
backgroundColor: greyScale[300],
color: greyScale[500],
cursor: 'not-allowed',
boxShadow: 'none',
transform: 'none',
'&:hover': { backgroundColor: greyScale[300], transform: 'none', boxShadow: 'none' },
},

...($loading && {
'&:hover': { backgroundColor: colors.main, transform: 'none' },
}),

...($fullWidth && { width: '100%' }),

transition: theme.transitions.create(
['background-color', 'box-shadow', 'transform', 'border-color'],
{ duration: theme.transitions.duration.short }
),

// ripple decorativo
'&::before': {
content: '""',
position: 'absolute',
inset: 0,
background: 'radial-gradient(circle, transparent 1%, rgba(255,255,255,0.1) 1%)',
backgroundSize: '15000%',
transition: 'background-size 0.3s',
pointerEvents: 'none',
},
'&:active::before': { backgroundSize: '100%', transition: 'background-size 0s' },
};
});

export const LoadingSpinner = styled(CircularProgress)({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: 'inherit',
});

export const ButtonContent = styled('span')<{ $loading: boolean }>(({ $loading }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
opacity: $loading ? 0 : 1,
transition: 'opacity 0.2s ease',
}));

93 changes: 93 additions & 0 deletions src/components/Button/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { render, screen } from '../../test/test-utils';
import { describe, it, expect, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

describe('Button', () => {
it('renderiza con props por defecto', () => {
render(<Button data-testid="btn">Hola</Button>);
const btn = screen.getByTestId('btn');
expect(btn).toBeInTheDocument();
expect(btn).toHaveTextContent('Hola');
expect(btn).not.toBeDisabled();
expect(btn).toHaveAttribute('type', 'button'); // default
});

it('dispara onClick cuando está habilitado', async () => {
const onClick = vi.fn();
render(<Button data-testid="btn" onClick={onClick}>Click</Button>);
await userEvent.click(screen.getByTestId('btn'));
expect(onClick).toHaveBeenCalledTimes(1);
});

it('no dispara onClick si está disabled', async () => {
const onClick = vi.fn();
render(<Button data-testid="btn" disabled onClick={onClick}>Click</Button>);
const btn = screen.getByTestId('btn');
expect(btn).toBeDisabled();
await userEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});

it('muestra spinner y bloquea interacción en loading', async () => {
const onClick = vi.fn();
render(<Button data-testid="btn" loading onClick={onClick}>Loading</Button>);
const btn = screen.getByTestId('btn');
expect(btn).toBeDisabled();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
await userEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});

it.each([
['submit' as const],
['reset' as const],
['button' as const],
])('soporta type="%s"', async (type) => {
render(<Button data-testid="btn" type={type}>X</Button>);
expect(screen.getByTestId('btn')).toHaveAttribute('type', type);
});

it.each([
['primary' as const],
['secondary' as const],
['success' as const],
['error' as const],
])('renderiza variante "%s"', (variant) => {
render(<Button data-testid="btn" variant={variant}>{variant}</Button>);
expect(screen.getByTestId('btn')).toBeInTheDocument();
});

it.each([
['small' as const],
['medium' as const],
['large' as const],
])('renderiza size "%s"', (size) => {
render(<Button data-testid="btn" size={size}>size</Button>);
expect(screen.getByTestId('btn')).toBeInTheDocument();
});

it.each([
['rounded' as const],
['square' as const],
['pill' as const],
])('renderiza shape "%s"', (shape) => {
render(<Button data-testid="btn" shape={shape}>shape</Button>);
expect(screen.getByTestId('btn')).toBeInTheDocument();
});

it('renderiza start y end icon', () => {
render(
<Button data-testid="btn"
startIcon={<span data-testid="start"> S</span>}
endIcon={<span data-testid="end"> E</span>}
>
Iconos
</Button>
);
expect(screen.getByTestId('btn')).toBeInTheDocument();
expect(screen.getByTestId('start')).toBeInTheDocument();
expect(screen.getByTestId('end')).toBeInTheDocument();
});
});

54 changes: 54 additions & 0 deletions src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';
import type { ButtonProps } from './Button.types';
import { StyledButton, LoadingSpinner, ButtonContent } from './Button.styles';
import { DEFAULT_PROPS } from './Button.constants';

/** Button con variantes, tamaños y formas (MUI base + estilos custom) */
export const Button: React.FC<ButtonProps> = ({
variant = DEFAULT_PROPS.variant,
size = DEFAULT_PROPS.size,
shape = DEFAULT_PROPS.shape,
loading = DEFAULT_PROPS.loading,
fullWidth = DEFAULT_PROPS.fullWidth,
disabled = DEFAULT_PROPS.disabled,
startIcon,
endIcon,
children,
onClick,
type = 'button',
'data-testid': dataTestId,
...rest
}) => {
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (loading || disabled) {
event.preventDefault();
return;
}
onClick?.(event);
};

return (
<StyledButton
{...rest}
variant="contained"
type={type}
disabled={disabled || loading}
onClick={handleClick}
data-testid={dataTestId}
$variant={variant}
$size={size}
$shape={shape}
$loading={loading}
$fullWidth={fullWidth}
>
{loading && <LoadingSpinner size={size === 'small' ? 16 : size === 'large' ? 24 : 20} />}
<ButtonContent $loading={loading}>
{startIcon && <span>{startIcon}</span>}
{children}
{endIcon && <span>{endIcon}</span>}
</ButtonContent>
</StyledButton>
);
};

export default Button;
33 changes: 33 additions & 0 deletions src/components/Button/Button.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ButtonProps as MuiButtonProps } from '@mui/material/Button';

export type ButtonVariant = 'primary' | 'secondary' | 'success' | 'error';
export type ButtonSize = 'small' | 'medium' | 'large';
export type ButtonShape = 'rounded' | 'square' | 'pill';

export interface ButtonProps extends Omit<MuiButtonProps, 'variant' | 'color'> {
/** Color variant */
variant?: ButtonVariant;
/** Size */
size?: ButtonSize;
/** Shape (border radius presets) */
shape?: ButtonShape;
/** Loading state */
loading?: boolean;
/** Left icon */
startIcon?: React.ReactNode;
/** Right icon */
endIcon?: React.ReactNode;
/** Take full width */
fullWidth?: boolean;
/** Test id */
'data-testid'?: string;
}

export interface StyledButtonProps {
$variant: ButtonVariant;
$size: ButtonSize;
$shape: ButtonShape;
$loading: boolean;
$fullWidth: boolean;
}