From 1faa93ab32bbdf09e9a9292f9e4d137413c6b3f3 Mon Sep 17 00:00:00 2001 From: luismi Date: Fri, 3 Oct 2025 15:36:18 -0400 Subject: [PATCH] Component - Storybook --- src/components/Button/Button.constants.ts | 42 +++++++++ src/components/Button/Button.stories.tsx | 46 ++++++++++ src/components/Button/Button.styles.ts | 105 ++++++++++++++++++++++ src/components/Button/Button.test.tsx | 94 +++++++++++++++++++ src/components/Button/Button.tsx | 55 ++++++++++++ src/components/Button/Button.types.ts | 33 +++++++ 6 files changed, 375 insertions(+) create mode 100644 src/components/Button/Button.constants.ts create mode 100644 src/components/Button/Button.stories.tsx create mode 100644 src/components/Button/Button.styles.ts create mode 100644 src/components/Button/Button.test.tsx create mode 100644 src/components/Button/Button.tsx create mode 100644 src/components/Button/Button.types.ts diff --git a/src/components/Button/Button.constants.ts b/src/components/Button/Button.constants.ts new file mode 100644 index 0000000..31db23f --- /dev/null +++ b/src/components/Button/Button.constants.ts @@ -0,0 +1,42 @@ +import type { ButtonVariant, ButtonSize, ButtonShape } from './Button.types'; + +export const BUTTON_VARIANTS: Record = { + primary: 'primary', + secondary: 'secondary', + success: 'success', + error: 'error', +} as const; + +export const BUTTON_SIZES: Record = { + small: 'small', + medium: 'medium', + large: 'large', +} as const; + +export const BUTTON_SHAPES: Record = { + 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; + diff --git a/src/components/Button/Button.stories.tsx b/src/components/Button/Button.stories.tsx new file mode 100644 index 0000000..c9aaf77 --- /dev/null +++ b/src/components/Button/Button.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Button } from './Button'; +import { BUTTON_VARIANTS, BUTTON_SIZES, BUTTON_SHAPES } from './Button.constants'; + +const meta: Meta = { + 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; + +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' } }; + + diff --git a/src/components/Button/Button.styles.ts b/src/components/Button/Button.styles.ts new file mode 100644 index 0000000..c75d31e --- /dev/null +++ b/src/components/Button/Button.styles.ts @@ -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('$'), +})(({ 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', +})); + diff --git a/src/components/Button/Button.test.tsx b/src/components/Button/Button.test.tsx new file mode 100644 index 0000000..a3c596f --- /dev/null +++ b/src/components/Button/Button.test.tsx @@ -0,0 +1,94 @@ +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(); + 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(); + await userEvent.click(screen.getByTestId('btn')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('no dispara onClick si está disabled', async () => { + const onClick = vi.fn(); + render(); + 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(); + 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(); + 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(); + expect(screen.getByTestId('btn')).toBeInTheDocument(); + }); + + it.each([ + ['small' as const], + ['medium' as const], + ['large' as const], + ])('renderiza size "%s"', (size) => { + render(); + expect(screen.getByTestId('btn')).toBeInTheDocument(); + }); + + it.each([ + ['rounded' as const], + ['square' as const], + ['pill' as const], + ])('renderiza shape "%s"', (shape) => { + render(); + expect(screen.getByTestId('btn')).toBeInTheDocument(); + }); + + it('renderiza start y end icon', () => { + render( + + ); + expect(screen.getByTestId('btn')).toBeInTheDocument(); + expect(screen.getByTestId('start')).toBeInTheDocument(); + expect(screen.getByTestId('end')).toBeInTheDocument(); + }); +}); + diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx new file mode 100644 index 0000000..fc5cc52 --- /dev/null +++ b/src/components/Button/Button.tsx @@ -0,0 +1,55 @@ +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 = ({ + 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) => { + if (loading || disabled) { + event.preventDefault(); + return; + } + onClick?.(event); + }; + + return ( + + {loading && } + + {startIcon && {startIcon}} + {children} + {endIcon && {endIcon}} + + + ); +}; + +export default Button; + diff --git a/src/components/Button/Button.types.ts b/src/components/Button/Button.types.ts new file mode 100644 index 0000000..3ad5c1c --- /dev/null +++ b/src/components/Button/Button.types.ts @@ -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 { + /** 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; +} +