From d3143bbbba01aeb42ff211933b11d15b4efb41f7 Mon Sep 17 00:00:00 2001 From: Chavarria12 Date: Thu, 9 Oct 2025 23:04:48 -0400 Subject: [PATCH] feat(pill-tag): add PillTag component, global tokens and tests --- src/components/PillTag/PillTag.constants.ts | 71 ++++++++ src/components/PillTag/PillTag.stories.tsx | 187 ++++++++++++++++++++ src/components/PillTag/PillTag.styles.ts | 79 +++++++++ src/components/PillTag/PillTag.test.tsx | 106 +++++++++++ src/components/PillTag/PillTag.tsx | 40 +++++ src/components/PillTag/PillTag.types.ts | 8 + src/components/PillTag/Pilltag.docs.mdx | 37 ++++ src/globals/colors.ts | 24 +++ src/globals/index.ts | 13 ++ src/globals/radii.ts | 14 ++ src/globals/typography.ts | 27 +++ 11 files changed, 606 insertions(+) create mode 100644 src/components/PillTag/PillTag.constants.ts create mode 100644 src/components/PillTag/PillTag.stories.tsx create mode 100644 src/components/PillTag/PillTag.styles.ts create mode 100644 src/components/PillTag/PillTag.test.tsx create mode 100644 src/components/PillTag/PillTag.tsx create mode 100644 src/components/PillTag/PillTag.types.ts create mode 100644 src/components/PillTag/Pilltag.docs.mdx create mode 100644 src/globals/colors.ts create mode 100644 src/globals/index.ts create mode 100644 src/globals/radii.ts create mode 100644 src/globals/typography.ts diff --git a/src/components/PillTag/PillTag.constants.ts b/src/components/PillTag/PillTag.constants.ts new file mode 100644 index 0000000..bd64ed5 --- /dev/null +++ b/src/components/PillTag/PillTag.constants.ts @@ -0,0 +1,71 @@ +import { colors, typography, radii } from '../../globals'; + +export const PILL_TAG_VARIANTS = ['primary', 'secondary'] as const; +export type PillTagVariant = (typeof PILL_TAG_VARIANTS)[number]; +export const VALIDATION_MESSAGES = { + EMPTY_LABEL: 'PillTag: label prop is required and cannot be empty', + INVALID_VARIANT: (variant: string) => + `PillTag: invalid variant "${variant}". Using "primary" as fallback.`, +} as const; +export const pillTagTokens = { + dimensions: { + primary: { + width: 251.51, + height: 85.1, + borderRadius: radii.pillTag.primary, + fontSize: 28.37, + padding: { + vertical: 2.5, + horizontal: 4, + }, + }, + secondary: { + width: 227.17, + height: 76.86, + borderRadius: radii.pillTag.secondary, + fontSize: 25.62, + padding: { + vertical: 2.2, + horizontal: 3.5, + }, + }, + }, + colors: { + background: colors.background.paper, + gradientStart: colors.primary.main, + gradientEnd: colors.primary.light, + }, + shadows: { + primary: { + default: `0px 18.91px 66.19px 0px ${colors.shadows.black15}`, + hover: `0px 20.91px 71.19px 0px ${colors.shadows.black20}`, + active: `0px 16.91px 56.19px 0px ${colors.shadows.black18}`, + }, + secondary: { + default: `0px 17.08px 59.78px 0px ${colors.shadows.black15}`, + hover: `0px 19.08px 64.78px 0px ${colors.shadows.black20}`, + active: `0px 15.08px 49.78px 0px ${colors.shadows.black18}`, + }, + }, + typography: { + fontFamily: typography.fontFamily.primary, + fontWeight: typography.fontWeight.medium, + lineHeight: typography.lineHeight.none, + letterSpacing: typography.letterSpacing.normal, + }, + states: { + hover: { + translateY: -2, + }, + active: { + scale: 0.98, + }, + disabled: { + opacity: 0.5, + }, + }, + transitions: { + duration: 200, + easing: 'ease-in-out', + }, +} as const; \ No newline at end of file diff --git a/src/components/PillTag/PillTag.stories.tsx b/src/components/PillTag/PillTag.stories.tsx new file mode 100644 index 0000000..b8fc3b5 --- /dev/null +++ b/src/components/PillTag/PillTag.stories.tsx @@ -0,0 +1,187 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { PillTag } from './PillTag'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import { Box, CssBaseline } from '@mui/material'; +import { colors, typography } from '../../globals'; + +const theme = createTheme({ + palette: { + primary: { + main: colors.primary.main, + light: colors.primary.light, + dark: colors.primary.dark, + contrastText: colors.primary.contrastText, + }, + secondary: { + main: colors.secondary.main, + light: colors.secondary.light, + dark: colors.secondary.dark, + contrastText: colors.secondary.contrastText, + }, + background: { + default: colors.background.default, + paper: colors.background.paper, + }, + }, + typography: { + fontFamily: typography.fontFamily.primary, + }, +}); + +const meta: Meta = { + title: 'Components/PillTag', + component: PillTag, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: ` +PillTag Component usando Design Tokens globales (src/globals/). + +**Características:** +- Tokens centralizados en src/globals/ +- Pixel-perfect según Figma +- WCAG AA compliant +- 10 tests unitarios + `, + }, + }, + }, + argTypes: { + label: { + control: 'text', + description: 'Texto a mostrar en el tag', + }, + variant: { + control: 'radio', + options: ['primary', 'secondary'], + description: 'Variante visual', + }, + clickable: { + control: 'boolean', + }, + disabled: { + control: 'boolean', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + render: (args) => ( + + + + + + + ), + args: { + label: 'Courses', + variant: 'primary', + }, +}; + +export const Secondary: Story = { + render: (args) => ( + + + + + + + ), + args: { + label: 'Test', + variant: 'secondary', + }, +}; + +export const Clickable: Story = { + render: (args) => ( + + + + + + + ), + args: { + label: 'Click me', + variant: 'primary', + clickable: true, + onClick: () => console.log('Clicked!'), + }, +}; + +export const Deleteable: Story = { + render: (args) => ( + + + + + + + ), + args: { + label: 'Delete me', + variant: 'secondary', + onDelete: () => console.log('Deleted!'), + }, +}; + +export const Disabled: Story = { + render: (args) => ( + + + + + + + ), + args: { + label: 'Disabled', + disabled: true, + }, +}; + +export const Multiple: Story = { + render: () => ( + + + + + + + + + + ), +}; +export const Comparison: Story = { + decorators: [ + (Story) => ( + + + + + + + ), + ], + render: () => ( + <> + + + + ), +}; \ No newline at end of file diff --git a/src/components/PillTag/PillTag.styles.ts b/src/components/PillTag/PillTag.styles.ts new file mode 100644 index 0000000..3afa0eb --- /dev/null +++ b/src/components/PillTag/PillTag.styles.ts @@ -0,0 +1,79 @@ +import { styled } from '@mui/material/styles'; +import Chip from '@mui/material/Chip'; +import type { ChipProps } from '@mui/material/Chip'; +import type { PillTagVariant } from './PillTag.constants'; +import { pillTagTokens } from './PillTag.constants'; + +interface PillTagStyledProps extends ChipProps { + pillVariant?: PillTagVariant; +} +export const PillTagStyled = styled(Chip, { + shouldForwardProp: (prop) => prop !== 'pillVariant', +})(({ theme, pillVariant = 'primary' }) => { + const dimensions = pillTagTokens.dimensions[pillVariant]; + const shadows = pillTagTokens.shadows[pillVariant]; + + return { + fontFamily: pillTagTokens.typography.fontFamily, + fontWeight: pillTagTokens.typography.fontWeight, + fontStyle: 'normal', + lineHeight: pillTagTokens.typography.lineHeight, + fontSize: `${dimensions.fontSize}px`, + letterSpacing: pillTagTokens.typography.letterSpacing, + textAlign: 'center', + textTransform: 'none', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + border: 'none', + boxSizing: 'border-box', + width: `${dimensions.width}px`, + height: `${dimensions.height}px`, + backgroundColor: pillTagTokens.colors.background, + borderRadius: + pillVariant === 'primary' + ? `${dimensions.borderRadius} 0px ${dimensions.borderRadius} ${dimensions.borderRadius}` + : `${dimensions.borderRadius} ${dimensions.borderRadius} ${dimensions.borderRadius} 0px`, + boxShadow: shadows.default, + padding: theme.spacing( + dimensions.padding.vertical, + dimensions.padding.horizontal + ), + transition: theme.transitions.create( + ['box-shadow', 'transform'], + { + duration: pillTagTokens.transitions.duration, + easing: pillTagTokens.transitions.easing, + } + ), + '& .MuiChip-label': { + display: 'block', + width: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + padding: 0, + background: `linear-gradient(180deg, ${pillTagTokens.colors.gradientStart} 0%, ${pillTagTokens.colors.gradientEnd} 100%)`, + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + backgroundClip: 'text', + }, + '&:hover': { + boxShadow: shadows.hover, + transform: `translateY(${pillTagTokens.states.hover.translateY}px)`, + }, + '&:active': { + boxShadow: shadows.active, + transform: `scale(${pillTagTokens.states.active.scale})`, + }, + '&.Mui-disabled': { + opacity: pillTagTokens.states.disabled.opacity, + pointerEvents: 'none', + boxShadow: 'none', + }, + '&:focus-visible': { + outline: `2px solid ${pillTagTokens.colors.gradientStart}`, + outlineOffset: '2px', + }, + }; +}); \ No newline at end of file diff --git a/src/components/PillTag/PillTag.test.tsx b/src/components/PillTag/PillTag.test.tsx new file mode 100644 index 0000000..0dd1848 --- /dev/null +++ b/src/components/PillTag/PillTag.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import { PillTag } from './PillTag'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { colors, typography } from '../../globals'; + +const testTheme = createTheme({ + palette: { + primary: { + main: colors.primary.main, + light: colors.primary.light, + }, + background: { + paper: colors.background.paper, + }, + }, + typography: { + fontFamily: typography.fontFamily.primary, + }, +}); + +const renderWithTheme = (ui: React.ReactElement) => { + return render({ui}); +}; + +describe('PillTag Component', () => { + let originalEnv: string | undefined; + + beforeEach(() => { + originalEnv = process.env.NODE_ENV; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.NODE_ENV = originalEnv; + } + }); + it('should render with label correctly', () => { + renderWithTheme(); + expect(screen.getByText('Courses')).toBeInTheDocument(); + }); + it('should render primary variant by default', () => { + renderWithTheme(); + const element = screen.getByTestId('pill-tag'); + expect(element).toBeInTheDocument(); + }); + it('should render secondary variant when specified', () => { + renderWithTheme(); + const element = screen.getByTestId('pill-tag'); + expect(element).toBeInTheDocument(); + }); + it('should not render when label is empty', () => { + const { container } = renderWithTheme(); + expect(container.firstChild).toBeNull(); + }); + it('should fallback to primary with invalid variant', () => { + process.env.NODE_ENV = 'development'; + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); + + renderWithTheme(); + expect(screen.getByTestId('pill-tag')).toBeInTheDocument(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + it('should handle onClick when clickable', () => { + const handleClick = vi.fn(); + renderWithTheme( + + ); + + const element = screen.getByTestId('pill-tag'); + fireEvent.click(element); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + it('should handle onDelete when provided', () => { + const handleDelete = vi.fn(); + renderWithTheme(); + + const deleteButton = screen.getByTestId('CancelIcon'); + fireEvent.click(deleteButton); + expect(handleDelete).toHaveBeenCalledTimes(1); + }); + it('should apply disabled state correctly', () => { + renderWithTheme(); + const element = screen.getByTestId('pill-tag'); + expect(element).toHaveClass('Mui-disabled'); + }); + it('should be keyboard accessible when clickable', () => { + const handleClick = vi.fn(); + renderWithTheme( + + ); + + const element = screen.getByRole('button'); + fireEvent.keyDown(element, { key: 'Enter', code: 'Enter' }); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + it('should use custom data-testid when provided', () => { + renderWithTheme(); + expect(screen.getByTestId('custom-pill')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/PillTag/PillTag.tsx b/src/components/PillTag/PillTag.tsx new file mode 100644 index 0000000..e66a024 --- /dev/null +++ b/src/components/PillTag/PillTag.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import type { PillTagProps } from './PillTag.types'; +import { PillTagStyled } from './PillTag.styles'; +import { + PILL_TAG_VARIANTS, + VALIDATION_MESSAGES, + type PillTagVariant, +} from './PillTag.constants'; + +export const PillTag: React.FC = ({ + label, + variant = 'primary', + 'data-testid': dataTestId = 'pill-tag', + ...rest +}) => { + if (!label || label.trim() === '') { + if (process.env.NODE_ENV === 'development') { + console.warn(VALIDATION_MESSAGES.EMPTY_LABEL); + } + return null; + } + const isValidVariant = PILL_TAG_VARIANTS.includes(variant); + + if (!isValidVariant && process.env.NODE_ENV === 'development') { + console.warn(VALIDATION_MESSAGES.INVALID_VARIANT(variant)); + } + + const validVariant: PillTagVariant = isValidVariant ? variant : 'primary'; + + return ( + + ); +}; + +export default PillTag; \ No newline at end of file diff --git a/src/components/PillTag/PillTag.types.ts b/src/components/PillTag/PillTag.types.ts new file mode 100644 index 0000000..60ff1e5 --- /dev/null +++ b/src/components/PillTag/PillTag.types.ts @@ -0,0 +1,8 @@ +import type { ChipProps } from '@mui/material/Chip'; +import type { PillTagVariant } from './PillTag.constants'; + +export interface PillTagProps extends Omit { + label: string; + variant?: PillTagVariant; + 'data-testid'?: string; +} \ No newline at end of file diff --git a/src/components/PillTag/Pilltag.docs.mdx b/src/components/PillTag/Pilltag.docs.mdx new file mode 100644 index 0000000..155327d --- /dev/null +++ b/src/components/PillTag/Pilltag.docs.mdx @@ -0,0 +1,37 @@ +import { Meta, Canvas, Story, ArgsTable } from '@storybook/addon-docs'; +import { PillTag } from './PillTag'; +import * as PillTagStories from './PillTag.stories'; + + + +# PillTag + +Componente reutilizable para mostrar categorías, tags o etiquetas en formato de píldora (pill/chip) con bordes redondeados y gradient de texto. + +## Propósito del componente + +Implementar un componente de etiqueta/píldora reutilizable para mostrar categorías como "Courses" y "Test" en un estilo de chip redondeado, siguiendo: + +- Metodología **Component-Driven Development (CDD)** +- Diseño **pixel-perfect** según Figma +- Cumplimiento de requisitos técnicos del proyecto +- Extensibilidad mediante props de MUI Chip + +## Características principales + +- **Dos variantes visuales:** `primary` y `secondary` (definidas en `.constants.ts`) +- **Diseño exacto según Figma:** dimensiones, colores, sombras específicas +- **Gradient de texto:** Linear gradient #B23DEB → #DE8FFF con WebKit +- **Fondo blanco:** #FFFFFF con drop shadow inferior para efecto elevado +- **Bordes redondeados asimétricos:** esquinas específicas según variante +- **Truncado automático:** texto largo se corta con ellipsis (...) +- **Accesibilidad WCAG AA:** keyboard navigation, focus visible, ARIA +- **Tests unitarios:** 14 tests con Vitest + Testing Library +- **Props extendiendo MUI:** hereda todas las props de ChipProps +- **Validaciones robustas:** label vacío, variant inválido con fallbacks + +## Instalación y uso + +### Importación +```tsx +import { PillTag } from '@/components/PillTag'; \ No newline at end of file diff --git a/src/globals/colors.ts b/src/globals/colors.ts new file mode 100644 index 0000000..4ef9440 --- /dev/null +++ b/src/globals/colors.ts @@ -0,0 +1,24 @@ + +export const colors = { + primary: { + main: '#B23DEB', + light: '#DE8FFF', + dark: '#9B1FD8', + contrastText: '#FFFFFF', + }, + secondary: { + main: '#E91E63', + light: '#F48FB1', + dark: '#C2185B', + contrastText: '#FFFFFF', + }, + background: { + default: '#F5F5F5', + paper: '#FFFFFF', + }, + shadows: { + black15: 'rgba(0, 0, 0, 0.15)', + black18: 'rgba(0, 0, 0, 0.18)', + black20: 'rgba(0, 0, 0, 0.20)', + }, +} as const; \ No newline at end of file diff --git a/src/globals/index.ts b/src/globals/index.ts new file mode 100644 index 0000000..b2a83c0 --- /dev/null +++ b/src/globals/index.ts @@ -0,0 +1,13 @@ +import { colors } from './colors'; +import { typography } from './typography'; +import { radii } from './radii'; + +export { colors } from './colors'; +export { typography } from './typography'; +export { radii } from './radii'; + +export const tokens = { + colors, + typography, + radii, +} as const; \ No newline at end of file diff --git a/src/globals/radii.ts b/src/globals/radii.ts new file mode 100644 index 0000000..aa94bef --- /dev/null +++ b/src/globals/radii.ts @@ -0,0 +1,14 @@ +export const radii = { + none: '0px', + sm: '4px', + md: '8px', + lg: '12px', + xl: '16px', + '2xl': '24px', + '3xl': '32px', + full: '9999px', + pillTag: { + primary: '42.55px', + secondary: '38.43px', + }, +} as const; \ No newline at end of file diff --git a/src/globals/typography.ts b/src/globals/typography.ts new file mode 100644 index 0000000..0060fc7 --- /dev/null +++ b/src/globals/typography.ts @@ -0,0 +1,27 @@ +export const typography = { + fontFamily: { + primary: '"Poppins", "Roboto", "Helvetica", "Arial", sans-serif', + }, + + fontWeight: { + regular: 400, + medium: 500, + semibold: 600, + bold: 700, + }, + + lineHeight: { + none: 1, + tight: 1.2, + normal: 1.5, + relaxed: 1.75, + }, + + letterSpacing: { + tighter: '-0.05em', + tight: '-0.025em', + normal: '0', + wide: '0.025em', + wider: '0.05em', + }, +} as const; \ No newline at end of file