From 9821e193f81ac3259bba63178a9d2e9aa7d69180 Mon Sep 17 00:00:00 2001 From: magaly Date: Fri, 3 Oct 2025 15:47:48 -0400 Subject: [PATCH] laboratorio2-10504566 --- package.json | 6 +- src/components/Button/Button.constants.ts | 42 +++++++++ src/components/Button/Button.stories.tsx | 47 ++++++++++ src/components/Button/Button.styles.ts | 105 ++++++++++++++++++++++ src/components/Button/Button.test.tsx | 95 ++++++++++++++++++++ src/components/Button/Button.tsx | 55 ++++++++++++ src/components/Button/Button.types.ts | 33 +++++++ yarn.lock | 6 +- 8 files changed, 383 insertions(+), 6 deletions(-) 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/package.json b/package.json index 1023c9c..aa587de 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,11 @@ "dependencies": { "@astrojs/react": "4.3.1", "@emotion/cache": "11.14.0", - "@emotion/react": "11.14.0", + "@emotion/react": "^11.14.0", "@emotion/server": "11.11.0", - "@emotion/styled": "11.14.1", + "@emotion/styled": "^11.14.1", "@mui/icons-material": "7.3.4", - "@mui/material": "7.3.4", + "@mui/material": "^7.3.4", "astro": "5.13.9", "react": "19.1.1", "react-dom": "19.1.1" 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..22c50a3 --- /dev/null +++ b/src/components/Button/Button.stories.tsx @@ -0,0 +1,47 @@ +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..8c52b27 --- /dev/null +++ b/src/components/Button/Button.test.tsx @@ -0,0 +1,95 @@ +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; +} + diff --git a/yarn.lock b/yarn.lock index 341bd06..3bb734c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -396,7 +396,7 @@ resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.9.0.tgz#745969d649977776b43fc7648c556aaa462b4102" integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ== -"@emotion/react@11.14.0": +"@emotion/react@^11.14.0": version "11.14.0" resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.14.0.tgz#cfaae35ebc67dd9ef4ea2e9acc6cd29e157dd05d" integrity sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA== @@ -436,7 +436,7 @@ resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.4.0.tgz#c9299c34d248bc26e82563735f78953d2efca83c" integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg== -"@emotion/styled@11.14.1": +"@emotion/styled@^11.14.1": version "11.14.1" resolved "https://registry.yarnpkg.com/@emotion/styled/-/styled-11.14.1.tgz#8c34bed2948e83e1980370305614c20955aacd1c" integrity sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw== @@ -901,7 +901,7 @@ dependencies: "@babel/runtime" "^7.28.4" -"@mui/material@7.3.4": +"@mui/material@^7.3.4": version "7.3.4" resolved "https://registry.yarnpkg.com/@mui/material/-/material-7.3.4.tgz#945d69bafd615aaadd29b3a06b91b02d51c26e09" integrity sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==