Files
qwen3-6-lora/data/raw/sanitized/plans/podemos-planificar-la-creaci-n-structured-sundae-agent-adc51160af6444d3c.md
T

698 lines
22 KiB
Markdown

# Button Component Implementation Plan
## 1. TypeScript Type Definitions
### Discriminated Union Variant Type
```typescript
type ButtonVariant =
| 'primary'
| 'outline-gold'
| 'outline-dark'
| 'outline-light'
| 'ghost'
| 'circular'
| 'icon-edit'
| 'icon-delete'
| 'file-upload';
```
### Props Interface (Discriminated Union via Generics)
```typescript
type TButtonBaseProps = {
/**
* Button variant — controls visual style and behavior.
*/
variant: ButtonVariant;
/**
* Button size: 'small' | 'medium' | 'large'.
* Maps to the design specs: primary/outline-gold=large, outline-dark/outline-light/ghost=medium,
* circular/icon-edit/icon-delete=small, file-upload=medium.
*/
size?: 'small' | 'medium' | 'large';
/**
* Whether the button is disabled.
*/
disabled?: boolean;
/**
* Click handler.
*/
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
/**
* Optional HTML button type attribute.
*/
type?: 'button' | 'submit' | 'reset';
/**
* Optional className for external styling overrides.
*/
className?: string;
/**
* Optional aria-label for accessibility.
*/
'aria-label'?: string;
};
type TTextButtonProps = TButtonBaseProps & {
/**
* Button text content. Required for text-based variants.
*/
children: React.ReactNode;
};
type TIconButtonProps = TButtonBaseProps & {
/**
* Icon children only — no text. For circular, icon-edit, icon-delete variants.
*/
children: React.ReactNode;
variant: 'circular' | 'icon-edit' | 'icon-delete';
};
type TFileUploadProps = TButtonBaseProps & {
children: React.ReactNode;
variant: 'file-upload';
/**
* File accept attribute (e.g., 'image/*', '.pdf').
*/
accept?: string;
};
type TButtonProps = TTextButtonProps | TIconButtonProps | TFileUploadProps;
```
**Rationale:**
- Using a union of `TButtonProps` variants enforces at the type level that icon-only variants (`circular`, `icon-edit`, `icon-delete`) are distinct from text variants. This prevents developers from passing text to icon-only buttons.
- `size` is optional — the component maps the variant to the correct size automatically, but allows override for flexibility.
- `TTextButtonProps` is the broadest type; `TIconButtonProps` and `TFileUploadProps` narrow the `variant` field. TypeScript will correctly narrow `children` and `variant` when discriminated.
---
## 2. Component Structure (JSX)
```tsx
const Button: FC<TButtonProps> = ({
variant,
size,
disabled = false,
onClick,
type = 'button',
className = '',
'aria-label': ariaLabel,
children,
accept,
}) => {
// Auto-size mapping: variant → size
const resolvedSize = size ?? variantToSize[variant];
// Build class name: base + variant + size + state
const classes = [
'Button',
`Button--${variant}`,
`Button--${resolvedSize}`,
disabled && 'Button--disabled',
className,
]
.filter(Boolean)
.join(' ');
// File-upload variant renders as a label wrapping an input
if (variant === 'file-upload') {
return (
<label className={classes} onClick={onClick}>
<input
type="file"
accept={accept}
style={{ display: 'none' }}
onChange={(e) => onClick?.(e as unknown as React.MouseEvent<HTMLButtonElement>)}
/>
{children}
</label>
);
}
return (
<button
className={classes}
type={type}
disabled={disabled}
onClick={onClick}
aria-label={ariaLabel}
>
{children}
</button>
);
};
```
**Key Design Decisions:**
- **`<button>` for all text/icon variants** — semantic, accessible, keyboard-focusable.
- **`<label>` for `file-upload`** — clicking the label triggers the hidden `<input type="file">`. This is the standard pattern for custom file upload buttons.
- **`variantToSize` map** — a constant object mapping each variant to its default size, keeping the component DRY.
- **CSS class composition** — BEM modifier pattern: `.Button--primary`, `.Button--large`, `.Button--disabled`.
---
## 3. SCSS Architecture
### File: `src/components/Button/style.scss`
```scss
// ─── Variables (design tokens) ───────────────────────────────────────────
$color-gold: #EABF2D;
$color-amber: #D4880F;
$color-dark: #1A1A2E;
$color-white: #FFFFFF;
$color-danger: #DE3626;
$color-input-border: #DADCE0;
$color-muted: #9AA0A6;
$color-gray: #6B6B7B;
$font-size-xs: 11px;
$font-size-sm: 12px;
$font-size-md: 14px;
$font-size-lg: 16px;
$font-weight-bold: 700;
$font-weight-normal: 400;
$border-radius-sm: 4px;
$border-radius-md: 6px;
$border-radius-pill: 20px;
$border-radius-circle: 28px;
$border-width: 1.5px;
// ─── Base ────────────────────────────────────────────────────────────────
.Button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
border: none;
font-family: inherit;
font-weight: $font-weight-bold;
text-decoration: none;
transition: all 0.2s ease;
user-select: none;
white-space: nowrap;
// Disabled state
&--disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
// ─── Size variants ───────────────────────────────────────────────────
&--small {
width: 36px;
height: 36px;
font-size: $font-size-sm;
border-radius: $border-radius-sm;
padding: 0;
}
&--medium {
height: 40px;
font-size: $font-size-sm;
padding: 0 16px;
}
&--large {
height: 40px;
font-size: $font-size-sm;
padding: 0 24px;
min-width: 170px;
}
// ─── Variant: primary ────────────────────────────────────────────────
&--primary {
background-color: $color-gold;
color: $color-dark;
border-radius: $border-radius-md;
font-weight: $font-weight-bold;
}
// ─── Variant: outline-gold ───────────────────────────────────────────
&--outline-gold {
background-color: $color-white;
border: $border-width solid $color-gold;
color: $color-amber;
border-radius: $border-radius-md;
font-weight: $font-weight-bold;
}
// ─── Variant: outline-dark ───────────────────────────────────────────
&--outline-dark {
background-color: $color-white;
border: $border-width solid $color-dark;
color: $color-dark;
border-radius: $border-radius-sm;
&:hover {
background-color: $color-amber;
color: $color-white;
}
}
// ─── Variant: outline-light ──────────────────────────────────────────
&--outline-light {
background-color: $color-dark;
border: $border-width solid $color-white;
color: $color-white;
border-radius: $border-radius-sm;
}
// ─── Variant: ghost ──────────────────────────────────────────────────
&--ghost {
background-color: $color-white;
border: $border-width solid $color-dark;
color: $color-dark;
border-radius: $border-radius-pill;
&:hover {
background-color: $color-amber;
color: $color-white;
}
}
// ─── Variant: circular ───────────────────────────────────────────────
&--circular {
width: 56px;
height: 56px;
border-radius: $border-radius-circle;
background-color: $color-white;
border: $border-width solid $color-dark;
color: $color-dark;
font-size: 20px;
padding: 0;
}
// ─── Variant: icon-edit ──────────────────────────────────────────────
&--icon-edit {
background-color: $color-white;
border: $border-width solid $color-gold;
color: $color-amber;
font-size: 16px;
&:hover {
background-color: $color-gold;
color: $color-white;
}
}
// ─── Variant: icon-delete ────────────────────────────────────────────
&--icon-delete {
background-color: $color-white;
border: $border-width solid $color-danger;
color: $color-danger;
font-size: 14px;
&:hover {
background-color: $color-danger;
color: $color-white;
}
}
// ─── Variant: file-upload ────────────────────────────────────────────
&--file-upload {
background-color: $color-white;
border: $border-width solid $color-gold;
color: $color-amber;
border-radius: $border-radius-sm;
font-weight: $font-weight-normal; // file-upload is NOT bold
&:hover {
background-color: $color-gold;
color: $color-white;
}
}
}
```
**SCSS Design Decisions:**
- **Variables at top** — all colors, sizes, and radii are tokenized for easy maintenance. If the design system changes a gold shade, it's one variable.
- **BEM modifier pattern** — `.Button--primary`, `.Button--circular`, etc. Each variant is a separate BEM modifier block.
- **Hover states** — `outline-dark`, `ghost`, `icon-edit`, `icon-delete`, and `file-upload` all have hover states defined in the design. These are handled with `&:hover` selectors.
- **`file-upload` is the only non-bold variant** — explicitly set `font-weight: $font-weight-normal`.
- **Size overrides** — `circular` explicitly sets `width: 56px; height: 56px` to override the medium size defaults. `icon-edit` and `icon-delete` use the small size defaults.
- **`padding: 0` for icon variants** — ensures icon-only buttons don't add unwanted horizontal padding.
---
## 4. Storybook Stories Structure
### File: `src/components/Button/Button.stories.tsx`
```tsx
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { Button } from '@components';
const meta: Meta<typeof Button> = {
title: 'BL Consultores/Button',
component: Button,
parameters: {
docs: {
description: {
component: 'A versatile button component with 9 variants for all BL Consultores UI contexts.',
},
},
},
argTypes: {
variant: {
control: 'select',
options: [
'primary',
'outline-gold',
'outline-dark',
'outline-light',
'ghost',
'circular',
'icon-edit',
'icon-delete',
'file-upload',
],
description: 'Visual variant of the button',
},
size: {
control: 'select',
options: ['small', 'medium', 'large', undefined],
description: 'Button size (auto-detected from variant if not specified)',
},
disabled: {
control: 'boolean',
description: 'Whether the button is disabled',
},
type: {
control: 'select',
options: ['button', 'submit', 'reset'],
description: 'HTML button type attribute',
},
children: {
control: 'text',
description: 'Button content (text or icon)',
},
'aria-label': {
control: 'text',
description: 'Accessibility label for icon-only buttons',
},
accept: {
control: 'text',
description: 'File accept attribute (file-upload variant only)',
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
// ─── Text Button Variants ────────────────────────────────────────────────
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Primary Button',
},
};
export const OutlineGold: Story = {
args: {
variant: 'outline-gold',
children: 'Outline Gold',
},
};
export const OutlineDark: Story = {
args: {
variant: 'outline-dark',
children: 'Outline Dark',
},
};
export const OutlineLight: Story = {
args: {
variant: 'outline-light',
children: 'Outline Light',
},
};
export const Ghost: Story = {
args: {
variant: 'ghost',
children: 'Ghost Button',
},
};
export const FileUpload: Story = {
args: {
variant: 'file-upload',
children: 'Upload File',
accept: 'image/*,.pdf',
},
};
// ─── Icon-Only Variants ──────────────────────────────────────────────────
export const Circular: Story = {
args: {
variant: 'circular',
children: '✕',
'aria-label': 'Close',
},
};
export const IconEdit: Story = {
args: {
variant: 'icon-edit',
children: '✎',
'aria-label': 'Edit',
},
};
export const IconDelete: Story = {
args: {
variant: 'icon-delete',
children: '✕',
'aria-label': 'Delete',
},
};
// ─── States ──────────────────────────────────────────────────────────────
export const PrimaryDisabled: Story = {
args: {
variant: 'primary',
children: 'Primary Button',
disabled: true,
},
};
export const OutlineDarkHover: Story = {
args: {
variant: 'outline-dark',
children: 'Hover Me',
},
play: async ({ canvasElement }) => {
const button = canvasElement.querySelector('button');
if (button) {
button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
}
},
};
// ─── All Variants Overview ───────────────────────────────────────────────
export const AllVariants: Story = {
render: () => (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', padding: '24px' }}>
<Button variant="primary" children="Primary" />
<Button variant="outline-gold" children="Outline Gold" />
<Button variant="outline-dark" children="Outline Dark" />
<Button variant="outline-light" children="Outline Light" />
<Button variant="ghost" children="Ghost" />
<Button variant="circular" children="✕" aria-label="Close" />
<Button variant="icon-edit" children="✎" aria-label="Edit" />
<Button variant="icon-delete" children="✕" aria-label="Delete" />
<Button variant="file-upload" children="Upload" accept="image/*" />
</div>
),
};
```
**Storybook Design Decisions:**
- **Separate stories per variant** — each variant gets its own named story for easy navigation in the Storybook sidebar.
- **`AllVariants` overview story** — shows all variants together for quick visual comparison (common DS pattern).
- **`OutlineDarkHover` with `play`** — demonstrates the hover state via Storybook's Play function.
- **`aria-label` documented** — icon-only variants require it; the `argTypes` documents this.
---
## 5. Test Structure
### Jest Test: `src/components/__tests__/Button.test.tsx`
```tsx
import { render, screen } from '@testing-library/react';
import { Button } from '@components';
describe('<Button /> Component', () => {
it('renders text content', () => {
render(<Button variant="primary" children="Click me" />);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('applies the correct variant class', () => {
render(<Button variant="primary" children="Click me" />);
const button = screen.getByRole('button');
expect(button).toHaveClass('Button--primary');
});
it('renders as a button element', () => {
render(<Button variant="primary" children="Click me" />);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('is disabled when disabled prop is true', () => {
render(<Button variant="primary" children="Click me" disabled />);
expect(screen.getByRole('button')).toBeDisabled();
});
it('applies disabled class when disabled', () => {
render(<Button variant="primary" children="Click me" disabled />);
expect(screen.getByRole('button')).toHaveClass('Button--disabled');
});
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button variant="primary" children="Click me" onClick={handleClick} />);
screen.getByText('Click me').click();
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('renders file-upload as a label with hidden input', () => {
render(<Button variant="file-upload" children="Upload" accept="image/*" />);
expect(screen.getByRole('label')).toBeInTheDocument();
expect(screen.getByLabelText('')).toHaveAttribute('accept', 'image/*');
});
it('renders icon variant with aria-label', () => {
render(<Button variant="circular" children="✕" aria-label="Close" />);
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
});
it('accepts custom className', () => {
render(<Button variant="primary" children="Click me" className="custom-class" />);
expect(screen.getByText('Click me')).toHaveClass('custom-class');
});
it('renders with correct type attribute', () => {
render(<Button variant="primary" children="Submit" type="submit" />);
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
});
});
```
### Cypress Test: `src/components/__tests__/Button.test.cy.tsx`
```tsx
import { Button } from '@components';
describe('Testing Button Component', () => {
it('renders primary button with text', () => {
cy.mount(<Button variant="primary" children="Primary Button" />);
cy.get('button').should('have.class', 'Button--primary');
cy.get('button').contains('Primary Button');
});
it('renders all variants', () => {
const variants: Array<{ variant: string; label: string }> = [
{ variant: 'primary', label: 'Primary' },
{ variant: 'outline-gold', label: 'Outline Gold' },
{ variant: 'outline-dark', label: 'Outline Dark' },
{ variant: 'outline-light', label: 'Outline Light' },
{ variant: 'ghost', label: 'Ghost' },
{ variant: 'circular', label: 'Circular' },
{ variant: 'icon-edit', label: 'Icon Edit' },
{ variant: 'icon-delete', label: 'Icon Delete' },
{ variant: 'file-upload', label: 'File Upload' },
];
variants.forEach(({ variant, label }) => {
cy.mount(<Button variant={variant} children={label} />);
cy.get('.Button').should('have.class', `Button--${variant}`);
});
});
it('handles hover states for outline-dark', () => {
cy.mount(<Button variant="outline-dark" children="Hover Me" />);
cy.get('button').trigger('mouseover');
cy.get('button').should('have.css', 'background-color');
});
it('disables button when disabled prop is true', () => {
cy.mount(<Button variant="primary" children="Disabled" disabled />);
cy.get('button').should('be.disabled');
});
it('renders file-upload as a label', () => {
cy.mount(<Button variant="file-upload" children="Upload" accept="image/*" />);
cy.get('label').should('exist');
cy.get('input[type="file"]').should('exist');
});
});
```
**Test Design Decisions:**
- **Jest tests** — focus on React rendering behavior: DOM structure, props → class mapping, event handling, disabled state.
- **Cypress tests** — focus on visual/interaction behavior: CSS class presence, hover states, DOM structure for file-upload.
- **`file-upload` as `<label>`** — tested separately since it doesn't render a `<button>` element.
- **All 9 variants covered** — both Jest and Cypress verify each variant renders with its correct CSS class.
---
## 6. Export Update
### File: `src/components/index.tsx` (updated)
```tsx
export * from './Card';
export * from './Button';
```
### File: `src/components/Button/index.tsx` (exports)
```tsx
export { Button };
export type { TButtonProps, ButtonVariant };
```
---
## 7. Implementation Checklist
- [ ] Create `src/components/Button/index.tsx` with type definitions + component
- [ ] Create `src/components/Button/style.scss` with all variant styles
- [ ] Create `src/components/Button/Button.stories.tsx` with all stories
- [ ] Create `src/components/__tests__/Button.test.tsx` (Jest)
- [ ] Create `src/components/__tests__/Button.test.cy.tsx` (Cypress)
- [ ] Update `src/components/index.tsx` to export Button
- [ ] Run `npm test` — all tests pass
- [ ] Run `npm run storybook` — all stories render correctly
- [ ] Run `npm run cy:run` — all Cypress tests pass
---
## 8. Design Decisions Summary
| Decision | Rationale |
|----------|-----------|
| Single component, 9 variants | Reduces maintenance; variants are visual differences, not behavioral ones |
| Discriminated union types | TypeScript enforces which props are valid per variant at compile time |
| BEM CSS with variant modifiers | Matches existing Card pattern; easy to add/remove variants |
| `size` is optional, auto-mapped | Variant → size mapping is a design constant; override available for edge cases |
| `file-upload` renders `<label>` | Standard pattern for custom file inputs; keeps it keyboard-accessible |
| Hover states in SCSS | All hover transitions are defined in the design spec; handled purely in CSS |
| Separate Jest + Cypress tests | Jest for unit/rendering logic; Cypress for visual/DOM verification |
| Storybook stories per variant | Follows Storybook best practices; easy for designers/developers to browse |