698 lines
22 KiB
Markdown
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 |
|