22 KiB
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
TButtonPropsvariants 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. sizeis optional — the component maps the variant to the correct size automatically, but allows override for flexibility.TTextButtonPropsis the broadest type;TIconButtonPropsandTFileUploadPropsnarrow thevariantfield. TypeScript will correctly narrowchildrenandvariantwhen 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>forfile-upload— clicking the label triggers the hidden<input type="file">. This is the standard pattern for custom file upload buttons.variantToSizemap — 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 states —
outline-dark,ghost,icon-edit,icon-delete, andfile-uploadall have hover states defined in the design. These are handled with&:hoverselectors. file-uploadis the only non-bold variant — explicitly setfont-weight: $font-weight-normal.- Size overrides —
circularexplicitly setswidth: 56px; height: 56pxto override the medium size defaults.icon-editandicon-deleteuse the small size defaults. padding: 0for 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.
AllVariantsoverview story — shows all variants together for quick visual comparison (common DS pattern).OutlineDarkHoverwithplay— demonstrates the hover state via Storybook's Play function.aria-labeldocumented — icon-only variants require it; theargTypesdocuments 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-uploadas<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.tsxwith type definitions + component - Create
src/components/Button/style.scsswith all variant styles - Create
src/components/Button/Button.stories.tsxwith all stories - Create
src/components/__tests__/Button.test.tsx(Jest) - Create
src/components/__tests__/Button.test.cy.tsx(Cypress) - Update
src/components/index.tsxto 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 |