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

22 KiB

Button Component Implementation Plan

1. TypeScript Type Definitions

Discriminated Union Variant Type

type ButtonVariant =
  | 'primary'
  | 'outline-gold'
  | 'outline-dark'
  | 'outline-light'
  | 'ghost'
  | 'circular'
  | 'icon-edit'
  | 'icon-delete'
  | 'file-upload';

Props Interface (Discriminated Union via Generics)

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)

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

// ─── 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 statesoutline-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 overridescircular 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

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

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

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)

export * from './Card';
export * from './Button';

File: src/components/Button/index.tsx (exports)

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